Individual model auth: per-user Claude/ChatGPT accounts drive each turn - #707
Conversation
…oarding Under HARNESS=claude with only CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_AUTH_TOKEN), turns work but the deployment reported itself unconfigured: surface-config's modelProviderConfigured only consulted the credential store, and the admin onboarding badge said 'Needs a key'. harnessCarriedModelAuth(config) names the provider a harness authenticates on its own (claude -> anthropic via OAuth/auth token, codex -> openai via CODEX_ACCESS_TOKEN). It is OR'd into modelProviderConfigured and exposed to the admin as a sibling harnessAuth field on GET /v1/admin/model-providers. The credential-store statuses stay untouched: anthropic still reports absent, because those keys feed pi-transport calls and deleting or adding them is independent of harness OAuth.
Co-Authored-By: QM <qm@ycombinator.com>
Invert PR 690's auth flow: the subscription login is a per-user keychain credential, core is the single custodian and refresher, and harnesses receive derived ephemeral material at spawn (Codex: minimal auth.json without the refresh token; Claude: injected env token). - CodexAuthStore abstraction: keychainCodexAuthStore (production, CODEX_AUTH_CREDENTIAL) and fileCodexAuthStore (local dev, CODEX_AUTH_FILE), both with central refresh and single-flight rotation - claude harness authEnv hook + keychainHarnessAuthEnv (CLAUDE_AUTH_CREDENTIAL) - child auth.json never carries the refresh token; no sync-back path, so the lock-file persistence machinery and JWKS re-verification are gone - production ban now applies only to the file path; keychain path is the supported production route
When the new org-wide "Individual authorization for AI usage" toggle is on, every user connects their own AI account before chatting, and their turns run on that account instead of the org's shared credentials: - Claude subscription sign-in (PKCE against claude.ai) routes the user's turns to the claude harness with their CLAUDE_CODE_OAUTH_TOKEN injected per turn. - ChatGPT subscription sign-in (device-code flow against auth.openai.com) routes to the codex harness with a per-turn auth.json written into the jail; refreshed tokens persist back to the per-user store. - A pasted Anthropic/OpenAI API key routes to the pi harness with that key resolved per turn (org keys are never used for individual-auth turns). Backing pieces: an encrypted per-user credential store (user, provider) over the durable artifact map; core routes for status/connect/disconnect with OAuth start/poll/complete; token refresh before turns with timeouts on every provider call; the harness router driven by a pure, tested routing function; a first-login connect gate and a self-serve manage panel in the web UI (subscription or API key per provider, click-to-copy device code, disconnect to switch); the composer's model/harness picker hidden when the account decides the runtime; and the deployment-level provider pre-flight skipped for individual-auth turns so per-user credentials aren't refused before resolution.
…olate credentials Correctness fixes from the fresh-context review of the individual-auth work: - Fail closed: a human turn under individual auth with no connected account is refused with a connect prompt instead of silently running on the org's shared claude/codex credentials; automation/cron/ambient turns bypass individual auth and stay on org credentials. - Pin the per-user runtime through the harness router so the forced claude/codex/pi override is not rejected by the approved-harness list. - Strip ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN from the claude child env on subscription turns so the user's OAuth token is the only credential. - Restore the codex jail auth.json to org state after every per-user turn, serialize per-user and org turns on the same lock, and guard the per-user token write-back with lock ownership plus account-lineage verification so concurrent turns cannot cross-contaminate stored credentials. - Hard-delete disconnected credentials and re-check connection state before persisting refreshed tokens, so a disconnect cannot be resurrected by an in-flight turn and revoked secrets do not linger in Postgres. - /me fails closed (503) when the auth status is unavailable instead of letting unconnected users bypass the gate, and fetches its three core calls in parallel; the ChatGPT device poll stops at code expiry with a clear retry message. - Reuse the admin provider-key validator (keeping providerBaseUrl overrides), the connectors PKCE helpers, and the codex JWT helpers instead of local copies; report status as a single connections shape read without decrypting secrets; errMessage in route catches; drop the duplicate flush-key spread in config-store.
|
A high-effort fresh-context review (8 finder lenses + adversarial verification) ran against this diff and confirmed 10 findings; all are resolved in 80f74af: Auth enforcement (the serious ones)
Gates & UX correctness
Reuse/cleanup
Verified after the fixes: core affected suites 82/82, web-ui 565/565, typecheck+lint clean, and a live dev-instance turn on a real Claude subscription routed Known deferrals (structure, not correctness): consolidating the three turn-auth fields into one discriminated union on HarnessTurnInput, extracting the codex lock-acquire loop shared by the org and per-user branches, and /:provider-style OAuth route naming. |
…AuthStore Merges origin/subscription-auth (keychain custody for subscription harness auth) and re-expresses the individual-auth per-user paths on its custody model: children receive derived ephemeral material only, core is the sole refresher, and the write-back machinery this branch previously carried is gone. - Per-user Codex turns validate the user's tokens with codexOAuthAuthFromValue and materialize refresh-token-free child auth via prepareCodexHome; the jail auth is restored to org state after the turn. - Codex turns that touch OAuth or per-user auth serialize through a single in-process slot once individual auth has been used, so concurrent turns from different identities cannot run on each other's accounts; API-key orgs that never use individual auth keep full concurrency. - Per-user Claude turns layer the user's CLAUDE_CODE_OAUTH_TOKEN over the keychain authEnv material, stripping org API keys from the child env. - onCodexAuthRefresh is gone: the orchestrator already refreshes and persists per-user tokens before the turn, matching the custody model.
|
Merged @ReganBell's keychain-custody commit (c1dd286 from #690) into this branch and re-expressed the per-user paths on its custody model — this PR now carries both bodies of work as one:
Verified after the merge: typecheck + lint clean, harness/auth suites 76/76 (one known-flaky codex cancellation test passes in isolation and on rerun), and a live dev-instance turn on a real Claude subscription routed Open custody question for @ReganBell: per-user subscription tokens currently live in the new With c1dd286 now contained here, #690 can be closed in favor of this PR. |
Replace the parallel user_model_credentials store with a facade over the org keychain: each (user, provider) AI login is an ordinary keychain credential owned by that user (service model-anthropic / model-openai, origin individual-model-auth). Keychain encryption, ownership checks, admin visibility, and credential removal now cover AI logins for free — no second custody system, no second key derivation. - keychain: add readOwnSecret (owner-only decrypt of an env credential; no grant machinery) - store: same UserModelCredentialStore interface, keychain-backed; token expiry stays inside the payload so an expired access token still surfaces its refresh token to the pre-turn refresh - wiring: store now wraps credentialStore; user_model_credentials artifact map dropped (no production data existed) - tests assert unified custody: AI logins appear in listByOwner without secrets, strangers cannot decrypt, keychain.remove disconnects
|
Pushed |
Removes the shared-jail auth swap and the global auth-slot mutex that serialized every per-user (and, once seen, every org) Codex turn through one process. A per-user turn now spawns its own app-server with its own jail and its own derived auth.json (~0.5s, ~9MB measured), closed at turn end; the org runtime is untouched and never blocked. - extract buildServer() so shared and ephemeral runtimes use identical notification/request handlers (states already key on their server) - spawn semaphore (default 8) bounds concurrent process launches - close() also tears down any in-flight ephemeral servers - cross-account leakage is now structurally impossible: no shared mutable auth.json, no swap/restore, no restore-failure swallow - new test: concurrent alice/bob/org turns each see only their own account, no jail ever holds a refresh token, org credential on disk is untouched This matches how other Codex embedders behave (official SDK spawns a process per turn; Buzz pools processes of a single identity) — nobody multiplexes accounts through one process.
Fixes review points 3 and 4 with the existing machinery instead of a parallel implementation: - OAuthToken/CredentialRefresh gain optional idToken/accountId (encrypted id token; other connectors unaffected); putConnectorToken stores them, refresh preserves them across rotations - refreshAndStore now compare-and-sets before persisting a rotation, so a losing concurrent flight can no longer clobber a newer refresh token - new Keychain.connectorDerivedAuth: fresh access+id token+account id, refreshed single-flight when stale; the refresh token never leaves the keychain record - wiring registers auth.openai.com and claude.ai on the refresh dispatch, reusing subscription-oauth's refresh functions - user store facade: OAuth logins are connector tokens (API keys stay user-owned credentials); one connection per provider; new derivedOAuth - orchestrator drops its own 60s-heuristic refresh + write-back entirely - CodexTurnAuth loses refreshToken: the harness boundary only ever sees derived material (childCodexAuthFromDerived validates the account claim) - new test: two concurrent derivedOAuth calls on a stale token refresh exactly once and both see the rotated token
Conflicts: src/wiring.ts (kept main's hydrateModelCatalog while preserving the runtimePinned short-circuit), shell.css (kept both appended blocks).
Replaces the hand-rolled device-code flow (undocumented auth.openai.com endpoints, hardcoded client id, manual PKCE relay) with the vendored app-server's supported surface: account/login/start type chatgptDeviceCode + account/login/completed. A throwaway app-server runs against a temp CODEX_HOME; on approval we harvest the auth.json it wrote (access/refresh/id token, account claim validated) into the keychain and tear the process down. Version-matched by construction — the login flow ships with the same binary that consumes the tokens. - new src/model/codex-device-login.ts (start/poll/close, 15-min TTL, one-shot harvest, sweep of expired logins) - user-model-auth routes swap onto it; poll no longer needs userCode - dead flow pruned from subscription-oauth.ts (Claude PKCE and both refresh functions stay) - tests: fake codex binary drives approve + deny paths
The seven /v1/user-model-auth routes were registered auth:'either' but never added to the agent-api catalog, tripping the parity and route-auth-conformance suites. They are user-facing (the web UI calls them source-signed with portal identity + principal binding via user-scoped-routes); the agent has no business managing a person's AI accounts, so 'source' is the correct contract. Also runs the repo prettier over 10 files that had drifted (Lint check).
lint's knip step failed on 13 unused exports, all dead weight from the 7ae6a31 refactors: unexport perUserClaudeEnv and the user-store types, drop the codex-auth barrel's unused re-exports, and delete isCodexOAuthJwt/codexOAuthIdToken/readJsonFile/codexOAuthAccessToken, which now have no callers. eslint, knip, prettier, typecheck all green locally.
When the org runs the pi harness, a person's ChatGPT OAuth login no longer forces their turns onto the codex harness: pi serves them via pi-ai's built-in openai-codex provider (Codex Responses backend), authenticated with the keychain-derived access token — the account claim rides inside the JWT, so no refresh token and no extra headers cross the harness boundary. - pi-models: "codex/<id>" namespace resolves against pi-ai's openai-codex provider; un-prefixed ids keep resolving to the metered openai provider, so serving mode can never silently flip - routing: preferredHarness parameter; openai OAuth + pi org routes to pi with the namespaced model (requested openai models are honored) - orchestrator: passes org runtime selection (or deployment fallback) as the preferred harness; pi-on-ChatGPT turns get providerKeys[openai-codex] = derived access token, runtimePinned - non-pi orgs and anthropic logins behave exactly as before
Updates and extends the
subscription-authwork in #690 (this machine cannot push to that PR's head branch, so this PR carries the full branch from a fork; #690 can be closed in its favor, or a maintainer can fast-forward its branch to this head).What this adds
An org-wide "Individual authorization for AI usage" toggle (admin → Governance). When on, every user connects their own AI account before chatting, and each of their turns runs on that account — never the org's shared credentials:
claudeharnessCLAUDE_CODE_OAUTH_TOKENinjected per turncodexharnessauth.jsonwritten into the jail; refreshed tokens persisted back to the user's storepiharnessturn.providerKeysPieces
user-model-credential-store— encrypted (existing connector-secret crypto), keyed(user, provider), over the durable artifact map (user_model_credentials).subscription-oauth— Claude PKCE (claude.ai authorize + console token exchange) and ChatGPT device-code (auth.openai.com), refresh for both, 15s timeouts on every call.individual-auth-routing(pure, unit-tested) picks provider/harness/model from the user's credential; orchestrator refreshes OAuth before the turn and threadsclaudeOauthToken/codexAuth(+ refresh write-back callback) onHarnessTurnInput.auth.jsonunder the existing turn auth lock and persists refreshed tokens to the per-user store instead of the shared file./v1/user-model-auth/*(status, api-key, disconnect, claude start/complete, chatgpt start/poll), all user-scoped-enforced; web-ui server proxies under/api/user-model-auth/*and/mecarriesindividualModelAuth+modelAuthConnected.Safety
buildModelRuntimereceives onlyturn.providerKeys, so an unconnected user cannot ride the org key.Testing
test/user-model-auth-injection.test.ts: store round-trip, routing matrix (apikey→pi, anthropic-oauth→claude, openai-oauth→codex, requested-provider precedence, no-cred→null), codexauth.jsonvalidity.[individual-auth] user=… harness=… auth=…).Demo
Exercised end-to-end in a local dev instance (portal → web-ui → core). Screenshots of the connect gate/manage panel to follow in a comment — the panel is the
✨ AI accountbutton in the sidebar footer with individual auth on.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.