영어 원문
이 설계 기록은 역사적 맥락과 원문의 의미를 보존하기 위해 영어로 제공합니다.
ADR-028: CLI OIDC login — short-lived, auto-renewing virtual keys¶
Date: 2026-07-28
Status: Accepted
Related: ADR-004 (OIDC admin authz — resource-server-only), ADR-010
(self-service key issuance, POST /admin/keys + GET /admin/whoami), ADR-026
(console SSO login — SPA-as-OAuth-public-client), ADR-023 (declarative
virtual keys — the CI/service-account path this ADR does not change)
Context¶
Onboarding today means a human copies a plaintext ik_... key out of the
console or inferplane keys create output and pastes it into
ANTHROPIC_API_KEY. That key never expires unless someone revokes it. The
static string itself is the risk: it survives in shell history, .env files,
CI logs, and laptops long after anyone remembers it exists.
Most of what's needed already exists. POST /admin/keys (ADR-010) already
accepts an OIDC bearer, re-checks AdminIdentity.Entitled(team), accepts
expires_at/owner, and audits admin_key_created. keystore.Resolve
already enforces expiry fail-closed. What's missing is (1) a CLI that drives
this without a browser copy-paste, and (2) a port a developer can actually
reach — the Helm chart deliberately keeps the admin plane (:9090) off the
default ingress path (persistence/ingress.admin.enabled both default
false, and the values.yaml comment says explicitly: keep key-issuance actions
on an operator-restricted path).
Claude Code's apiKeyHelper (~/.claude/settings.json) makes automatic
renewal viable: it re-invokes the configured command on a timer
(CLAUDE_CODE_API_KEY_HELPER_TTL_MS, default 5 min) and on every HTTP
401, and sends the helper's stdout as both Authorization: Bearer and
x-api-key — exactly the two headers KeyAuth already reads. A short-lived
key that gets silently swapped out from under an in-progress session is
therefore invisible to the user, closing the loop the field notes in
docs/customer-issue-analysis.md complain about (SSO device-code failures,
8-hour re-auth churn) — provided the design doesn't reintroduce that same
class of failure by depending on something else that can also expire.
Decision¶
Add inferplane login / token / logout to the existing binary, and a
new data-plane (not admin-plane) opt-in endpoint pair so a developer never
needs to reach :9090:
GET /v1/auth/config— unauthenticated, secret-free:{cli, issuer?, client_id?}. MirrorsGET /admin/auth/config(ADR-026) exactly; omitted (404) unlessoidc.cli_login.enabledis set.POST /v1/auth/key— OIDC-bearer-authenticated (see below), body is{"team": "..."}only. The server decidesexpires_at(fromoidc.cli_login.key_ttl, default 8h, clamped to [15m, 24h] at config load — never client-supplied, or "short-lived" would be a false claim),owner(the verifiedsub), andmetadata: {"source":"cli"}. Rate-limited per subject (10/min, burst 10) so one valid ID token can't grow the keys table without bound. Auditscli_key_created/cli_denied.DELETE /v1/auth/key— authenticates with the virtual key itself (KeyAuth, same as any other data-plane route) and revokes itself. Used bylogout; needs no OIDC round-trip.
A second, distinct OIDC client — never the console's¶
oidc.cli_login.client_id MUST differ from oidc.client_id. Two independent
reasons, either alone sufficient:
- It's structurally required.
adminauth.Verifierchecksaud ∋ client_idand, whenazpis present, requiresazp == client_id(OIDC Core §3.1.3.7, enforced atinternal/adminauth/oidc.go). An ID token minted for the CLI's own client would be rejected by aVerifierconfigured with the console'sclient_id, and vice versa — so the two simply cannot share one verifier instance.cliVerifierincmd/inferplane/gateway.gobuilds a secondadminauth.Verifier, keyed tocli_login.client_id, reusing the SAME battle-tested verification code (alg pin, skew, lazy discovery, JWKS-refetch rate limit) — zero new crypto. - Reuse would weaken the console. ADR-026's console client is a
secretless public client. Registering a CLI loopback redirect
(
http://127.0.0.1:<port>/callback) on that same client would let any local process complete a code flow with the console's audience. A distinctclient_idbounds that blast radius to the CLI's own registration.
No IdP refresh token is ever stored¶
The credential file (~/.config/inferplane/credentials.json, 0600, atomic
temp+rename, INFERPLANE_HOME-overridable) holds only the currently-minted
key, its expiry, and the TOFU-pinned issuer/client_id. It holds no IdP
refresh token. Two reasons:
- A refresh token is a stronger, gateway-unrevocable credential than the
ik_key it would protect — losing the file to disk theft would hand an attacker SSO, not a team-scoped, gateway-revocable, short-lived key. - Okta/Cognito/Auth0 default to refresh-token rotation with reuse detection.
A helper-timer renewal racing a second Claude Code window or a manual
inferplane tokenwould eventually present a consumed token, kill the whole token family, and silently log the developer out — reproducing, nondeterministically, the exact 8-hour-re-auth pain this feature exists to remove.
The consequence: token cannot silently renew across an interactive login
boundary. Renewal only happens two ways:
token's hot path: the cached key still has> renewBefore(5 min) of life left in laptop clock time —expires_atis enforced against server time inkeystore.Resolve, so the margin exists specifically to absorb a fast local clock. Zero network calls.--id-token-command <cmd>(set atlogintime, persisted): re-run the command, mint a fresh key, no browser. This is the CLI's only unattended renewal path — the kubectl exec-credential pattern ADR-004 already endorses, and it covers strictly more ground than an OAuth device-code fallback would (headless boxes, IdPs like Cognito that rejecthttploopback redirects entirely, and orgs that already haveaws/gcloud/azCLIs authenticated). Device flow was considered and rejected: it's ~80 more lines of polling/backoff state, and the field notes already blame device-code failures for part of the current pain.- Otherwise:
tokenfails fast with "session expired; run: inferplane login" — an interactive re-login, exactly like today, just far less frequent (once perkey_ttl, not once per static-key-never rotation... or rather: once is the ceiling, whereas today there's no rotation at all).
Browser flow: loopback Authorization Code + PKCE (RFC 8252)¶
inferplane login is its own OAuth public client running Authorization
Code + PKCE (S256, via golang.org/x/oauth2's built-in helpers — no hand-
rolled crypto) against a 127.0.0.1:<ephemeral-port>/callback redirect.
state is checked with a constant-time compare before anything else,
error before code; a non-/callback path 404s without consuming the
one-shot listener (a stray favicon request can't kill the login); the
callback response is Cache-Control: no-store / Referrer-Policy:
no-referrer and never echoes the code into the page HTML. No nonce is
sent: the ID token is exchanged over TLS with a PKCE verifier only this
process holds, and the gateway — not the CLI — is the party that verifies
the token's signature; a nonce protects a client that verifies the token
itself, which the CLI deliberately does not do (that's the gateway's job,
same posture as ADR-026's SPA).
Every gateway/issuer URL is validated https-or-loopback (validateEndpointURL
— the loopback exception exists only so a plain httptest.Server can stand
in for both gateway and IdP in tests, and for local dev). A discovered
authorization/token endpoint must additionally be same-origin-or-subdomain of
the validated issuer (sameOriginOrSubdomain), bounding a compromised or
misconfigured discovery document's blast radius. On first login, issuer and
client_id are pinned into the credential file (TOFU); a silent change for an
already-known gateway is refused unless --reset is passed — protection
against a later-compromised gateway URL redirecting an existing user's SSO to
an attacker-controlled IdP.
Attribution and governance, given rotating key_ids¶
- CLI-key spend is governed at the TEAM level only.
governance.gokeys per-key budget/TPM/RPM on"budget:key:"+keyIDwith a fixed-length window; since every login/renewal mints a newkey_id, a per-key limit would reset every rotation.POST /v1/auth/keydeliberately never setsBudgetUSDMicros/TPM/RPM. - Attribution survives rotation via the audit chain, not the keystore.
cli_key_createdrecordsUser: subalongsidekey_id— an operator answers "what did person X do" by chain lookup, not by treatingkey_idas a stable identity. This makes the audit chain's durability load-bearing in a new way:persistence.enabled: false(the Helm default) combined with CLI login is an unsupported combination — a pod restart wipes both the keystore and the audit WAL, and with ~1-3 key rows minted per developer per day (bounded byrenewBefore+ the mint rate limit), that's a much faster loss of the sub→key_id join than the single-static-key world ever had. Operators enablingoidc.cli_loginshould also enable persistent storage and a durable audit sink. ownercannot be spoofed by a teammate. Fixed as part of this ADR inadminapi.KeysHandler.createtoo (not just the new endpoint): a non-admin OIDC identity'sowneris always overridden to its ownsub, regardless of what the request body asks for. Closes a pre-existing hole in the console/API self-service path (ADR-010) where any team member could mint a key and attribute it to someone else.- Revoke-on-rotation was considered and rejected. A second Claude Code
process (or the same one, mid-request) may still hold the previous key up
to its own
apiKeyHelperTTL; revoking it on every rotation would break that process mid-session. The shortexpires_atis exactly the mechanism that retires a superseded key —logoutis the only path that revokes. - No background pruning of expired rows.
Resolveis fail-closed on expiry regardless of row count (internal/keystore/sqlite.go), so correctness doesn't depend on cleanup. With the mint rate limit and server-decided TTL, growth is bounded (~1-3 rows/developer/day) — akeys prunesubcommand is deferred untilGET /admin/keysresponse size actually hurts, not built speculatively.
Consequences¶
Positive¶
- A developer's laptop never holds a credential more powerful or longer-lived than a team-scoped virtual key good for at most 24h (config-clamped).
apiKeyHelper's existing 401-triggers-refetch behavior means key rotation is invisible mid-session — no more disruptive than today's static key, and the compromise window shrinks from "forever" tokey_ttl.- Zero new endpoint on the admin plane; deployments that intentionally
firewall
:9090to operators only are unaffected and can enable this purely on the already-public data plane. - CI/service-account provisioning is completely unchanged —
inferplane keys create,POST /admin/keys, and declarativevirtual_keys(ADR-023) all keep working exactly as before. This is a human-only, opt-in addition.
Negative¶
- Renewal past
key_ttlwithout--id-token-commandrequires an interactive re-login. This is a deliberate trade against storing a stronger, unrevocable IdP refresh token — but it meanskey_ttlsets a hard ceiling on how long a developer can go without touching a browser again, and operators should pick it with that in mind (8h default = once per workday). persistence.enabled: false+ CLI login silently degrades attribution (not availability —Resolvestill works) on every restart. Not enforced by code (a config validator can't see the persistence chart value from inside the binary); documented in the runbook instead.- A second OIDC app-client registration is now required per IdP for any
operator who wants both console SSO and CLI login. One more piece of IdP-
side setup, covered in
docs/runbooks/cli-login.md. - Cognito rejects
http://127.0.0.1:<any-port>/callback(no wildcard port support) — deployments on Cognito need either a fixed--portwith that exact URI registered, or--id-token-commandusing the AWS CLI's own SSO login instead of the browser PKCE path.
References¶
internal/server/authapi/— the two new handlerscmd/inferplane/login.go,cmd/inferplane/credentials.go— the CLIinternal/config/config.go—CLILoginConfig,validateOIDCdocs/runbooks/cli-login.md— IdP registration + troubleshootingdocs/customer-issue-analysis.md— the SSO/token-chain field pain this narrows, and the explicit case for keeping static keys for CI