OAuthCredentialStorePort — separate from the
read-only SecretManager — because access tokens expire on a 30-minute
cadence and refresh tokens rotate on every refresh. This page covers the full
lifecycle: how login flows work, where tokens are stored, how multi-account
routing resolves, how env-var bootstrap interacts with on-disk profiles, and
the threat model.
Token Sink
OAuth refresh tokens rotate on every refresh — each refresh exchanges the current refresh token for a fresh access token AND a fresh refresh token. The previous refresh token becomes invalid immediately. This means exactly one component per ChatGPT account can hold the live refresh token at any time. Two components racing producesrefresh_token_reused, which OpenAI treats as
a security event and auto-locks the account for 24 hours.
Practical consequence: pick one source of truth per ChatGPT account.
For a multi-machine, multi-account setup, give each Comis daemon its own
ChatGPT account, or use multi-account profiles
below to route different agents to different identities within a single
daemon.
Storage Backends
OAuth profiles are stored on disk in one of two backends, selected via thesecurity.storage config key:
| Backend | Path | Algorithm | Hot-reload |
|---|---|---|---|
encrypted (default) | oauth_profiles table inside secrets.db (same SQLite database as the encrypted secrets store) | AES-256-GCM with HKDF-SHA256 key derivation; per-row 12-byte IV and 16-byte auth tag | No — SQLite WAL writes do not surface as change events |
file | ~/.comis/auth-profiles.json | Plaintext JSON, mode 0o600 | Yes — chokidar 100ms debounce |
tmp -> fsync -> rename. Cross-process
refreshes coordinate through proper-lockfile — a Comis daemon and a
comis auth login CLI invocation against the same profile-id will serialize,
not race. The chokidar watcher means a fresh login from a different shell is
picked up by the running daemon within ~100ms; no daemon restart needed. The
auth-profiles.json file is rejected at boot if its mode is anything other
than 0o600 (group/world read is a configuration error, not a warning).
Encrypted mode requires SECRETS_MASTER_KEY. The daemon fails fast on
boot otherwise — no silent fallback to plaintext. The denormalized
expires_at column lets comis doctor scan profile expiries without
decrypting every row. Hot-reload is a known limitation: CLI-written profiles
need a daemon restart to take effect (the SQLite WAL journal masks the
file-change event chokidar relies on).
OAuth Exchange (How Login Works)
Codex PKCE flow
OpenAI Codex uses OAuth 2.0 with PKCE (Proof Key for Code Exchange):- The CLI / wizard generates a random 32-byte verifier (hex-encoded) and computes its SHA-256 challenge.
- The CLI / wizard generates a random state (CSRF token) and constructs
an authorization URL pointing at
auth.openai.comwith the challenge + state + the originator idcomis. - The user signs into ChatGPT and approves; the OAuth server redirects to a
callback URL —
http://127.0.0.1:1455/callbackfor local mode, the device-code verification URL for headless mode, orhttps://<your-host>/oauth/callback/openai-codexfor the gateway flow. - The CLI / wizard validates the returned state matches the one it sent
(rejecting
callback_validation_failedotherwise), exchanges the authorization code + verifier for an access + refresh token pair, and decodes the JWT to extract the email + accountId.
openai-codex:<email> (or a custom alias if --profile was supplied — see
multi-account profiles).
Anthropic setup-token flow
Anthropic Claude uses a different “OAuth” UX — the user runsclaude setup-token from Anthropic’s standalone CLI, copies a long-lived
token, and pastes it into the Comis wizard. This is not the PKCE flow
described above; it is a paste-token bootstrap from a tool Anthropic
maintains. Comis treats the resulting token as opaque and stores it via the
same OAuthCredentialStorePort, but no refresh happens — the token is valid
until Anthropic invalidates it.
Refresh and Expiry
Access tokens expire 30 minutes after issuance. The daemon checks the persistedexpires epoch-millisecond on every model call and refreshes
proactively when the access token is within a small buffer of expiry (the
current implementation uses a 60-second buffer, kept tight to maximize the
token’s useful life while still avoiding round-trip races). On the next LLM
call past that threshold, the refresh path runs.
Refresh runs under a per-profile-id file-lock:
- Acquire the cross-process lock via
proper-lockfile(5-retry exponential backoff: 50ms -> 100ms -> 200ms -> 500ms -> 1000ms). - Re-read the profile under the lock (a different process may have already
refreshed — if the cached
expiresis now past the buffer, return the newly-persisted access token without hitting the network). - If still expired-or-near, call
auth.openai.com’s token endpoint with the current refresh token under a 30-secondwithTimeout. - Persist the new access + new refresh + new expiry to disk (file or encrypted) atomically.
- Then release the lock.
refresh_token_reused race that plagued earlier designs in
which the refresh round-trip ran outside any lock. Implementations
specifically MUST NOT release the lock before the new credentials are
fsynced to disk.
Multi-Account Profiles
Each profile is keyed by<provider>:<identity>. For OpenAI Codex, identity
is the email from the OAuth response JWT — so
openai-codex:user@example.com and openai-codex:work@company.com coexist
inside one credential store. The CLI’s --profile flag overrides the
auto-derived id (the provider portion must still match --provider); this
lets you assign meaningful aliases like openai-codex:prod-bot regardless of
the underlying email.
Per-agent OAuth routing happens through agents.<id>.oauthProfiles in YAML:
~/.comis/config.yaml
oauthProfiles map (explicit pin), (b) the per-provider lastGood cache
(the last profile-id that successfully refreshed for this provider in this
process), and (c) the first available profile in the store for this
provider. Missing all three returns PROFILE_NOT_FOUND and the call fails
fast with an actionable hint — this is the routing equivalent of the
“explicit > sticky > arbitrary” precedence used for skill scoping.
Env-Var Bootstrap Precedence
For CI / Docker deployments, Comis supports pre-seeding an OAuth profile via theOAUTH_<PROVIDER> environment variable (e.g.,
OAUTH_OPENAI_CODEX).
The precedence rules are deliberately strict so an operator can never be
confused about which credential is in flight:
- R7a — Empty store seeds from env. When the credential store has no
profile for
<provider>andOAUTH_<PROVIDER>is set, the daemon parses the env var as JSON (access,refresh,expires,accountId,email), decodes the JWT identity, and writes the profile on first start. Subsequent refreshes update the on-disk profile, not the env var. - R7b — Stored profile wins. When the store already has a profile, the
daemon ignores the env var entirely. The stored profile is the source of
truth (it may have been refreshed since boot, in which case the env-var
refresh token is now stale and would trigger
refresh_token_reused). - R7c — WARN once on drift. When the env-var refresh token differs from
the stored profile’s refresh token, the daemon emits one WARN per
(provider, process) with
errorKind: "config_drift"andhint: "env-override-ignored". Operators clear drift by eithercomis auth logout --profile <id>(then restart re-bootstraps from env) or by deleting the env var. This is the env-var bootstrap path; the WARN is intentionally one-shot per process so noisy startup logs do not crowd out genuine errors.
Wizard vs CLI Parity Matrix
Where each login flow is reachable:| Login flow | comis init wizard | comis auth login CLI | Web dashboard |
|---|---|---|---|
Browser auto-open (callback on 127.0.0.1:1455) | check — “Browser (auto-open)“ | check — default on local mode | — |
| Browser manual paste | check — “Browser (manual paste)“ | check — --remote, or 15s+1s timeout fallback | — |
| Device code (short code on phone) | check — “Device code (phone)“ | check — --method device-code | — |
| Gateway callback (web “Connect ChatGPT” button) | — | — | check — GET /oauth/callback/:provider Hono route |
| Skip-for-now (defer login to a later command) | check — “Skip for now” | N/A | — |
Env-var bootstrap (OAUTH_OPENAI_CODEX) | N/A — wizard is interactive | N/A — same | — |
| Pre-generated token paste (legacy) | — REMOVED in 260504-gge | — | — |
Device code (phone) on
remote/headless hosts (SSH_CLIENT/SSH_TTY set or DISPLAY unset) and to
Browser (auto-open) on local desktops. The labels in the table are the
exact strings rendered by packages/cli/src/wizard/steps/04-credentials.ts
so that a user comparing this doc against their terminal sees the same
words.
Error Classification
Every OAuth failure is mapped to one of six structured error codes (errorKind) so the CLI can emit actionable hints and the daemon can
classify failures in structured logs:
errorKind | When | User-facing message + hint |
|---|---|---|
refresh_token_reused | Server says token already used (account auto-locked) | Re-authenticate with comis auth login --provider openai-codex |
invalid_grant | Refresh token rejected for another reason | Re-authenticate with comis auth login --provider openai-codex |
unsupported_region | OpenAI rejects the request based on country / network route | Set HTTPS_PROXY to a US-region proxy and retry; see HTTPS Proxy |
callback_validation_failed | State mismatch or missing code (stale browser tab) | Retry the login flow |
identity_decode_failed | OAuth response had no parseable identity claim | Retry; if persistent, open an issue |
callback_timeout | Browser callback never arrived (or upstream timed out) | Restart the login flow |
refresh_token_reused is matched
before invalid_grant because the former is a more specific subtype of
the latter, and a generic invalid_grant message would mask the
auto-lock-recovery hint.
Threat Model
- T-OAUTH-DISK-EXFIL — Disk-read attacker. An attacker with read
access to
~/.comis/could exfiltrate stored access and refresh tokens. Mitigation: Plaintext mode enforces0o600perms (user-only read); files with weaker modes are rejected at boot. Encrypted mode stores tokens as AES-256-GCM ciphertext with per-row IV / auth-tag, defeating raw-byte scans (verified by canary tests in the test suite). - T-OAUTH-REFRESH-RACE — Concurrent refresh race. Two daemon
instances or daemon+CLI refreshing the same token simultaneously could
produce
refresh_token_reused(which auto-locks the account at OpenAI). Mitigation: Per-profile cross-process file lock viaproper-lockfilewith 5-retry exponential backoff; refresh persistence happens before lock release, so concurrent callers see the rotated token via the lock-protected critical section. - T-OAUTH-ENV-DRIFT — Env-var drift. An operator updates
OAUTH_OPENAI_CODEXin.envafter a profile is already in the store; the daemon silently uses the stored profile. Mitigation: WARN-once-per-(provider, process) on drift witherrorKind: "config_drift"andhint: "env-override-ignored". Resolution is documented in the env-var bootstrap section above. - T-OAUTH-LOG-LEAK — Token in logs. OAuth tokens never appear in any
log line at any level (DEBUG included). Pino’s auto-redaction list is the
safety net (the canonical fields convention in
CLAUDE.mdexcludestoken,access,refresh,apiKey, etc. by name). Email identities are semi-redacted at the call site —user@example.combecomesu***@e***.com— before being logged, so a multi-account routing bug is still debuggable without revealing full email addresses to a log reader. - T-OAUTH-HUNG-REFRESH — DoS via slow OAuth server. An OAuth refresh
that hangs forever could starve concurrent LLM calls waiting on the file
lock. Mitigation: every refresh runs under a 30-second
withTimeout; on timeout the lock is released and the original error surfaces witherrorKind: "timeout"andhint: "auth_endpoint_unreachable".
EXAMPLE- prefixes for token values; no
real-shape JWT bodies are paste-able from this doc, which addresses
T-DOC-EXAMPLE-LEAK. The wizard-vs-CLI parity matrix is verbatim from the
RESEARCH source so that a doc edit cannot silently drift from the wizard
labels in 04-credentials.ts (T-DOC-MISDIRECTION).
Related
Secrets
SecretStorePort and the AES-256-GCM secrets store
Secret Manager Reference
Technical interface details
CLI: comis auth
Login, list, logout, status
Config: security.storage
YAML configuration reference
Env Var: OAUTH_OPENAI_CODEX
CI / Docker bootstrap
HTTPS Proxy
Proxy quirks for unsupported_region
