feat(accounts): ChatGPT credentials and refresh in the key store

Merged
CH
5d ago
+16883-20

P18 of the native-harness rollout (task "Bringing Codex or Claude Code Max plans into Luke"). It stacks on #4180 (P04).

This adds the chatgpt_oauth kind to account_provider_key. The desktop completes OpenAI's open-source Sign in with ChatGPT for Luke's hosted runtime (the self-hosted VM recipe) and hands the credentials to the service. From then on the service owns refreshes and revocation. Nothing reads the credentials yet beyond refresh; P21 is the first consumer. There is no UI; that is P19.

What changed

  • Storage: P04's one sealed blob (sealed_key, with AAD already bound to the kind) holds the access, refresh and ID tokens, the issued client_id, the account id (the ID token's validated subject) and the scopes. One additive nullable column, access_expires_at, records when the access token lapses, so a refresh can be judged due without opening the seal. Rewrap needs no change because it moves the data key only.
  • Host id, a deviation from the brief, agreed with the orchestrator: ext_agent_host_id lives in a new table, account_chatgpt_host, keyed by user and cascading with the account. It does not live on the credential row. OpenAI's guides ask for the host id to be persisted before the first sign-in and kept across sign-outs, and P04's row is NOT NULL-sealed and deleted on disconnect. The id is minted once (insert-on-conflict-do-nothing, so concurrent first reads agree) and served on GET /api/account/chatgpt/host.
  • Save: PUT /api/account/chatgpt/credentials takes {clientId, accountId, accessToken, refreshToken, idToken, expiresInSeconds, scopes}.
    • It shares the provider keys' 10-per-hour brake, seals the credentials, and saves them unverified. Nothing is sent to OpenAI.
    • It is refused as invalid when the grant lacks chatgpt.tokens.use.direct or when it names dynamic_agent_client.
    • Since P04c (#4217) it stands beside an OpenAI API key.
    • It answers with the provider keys' answer, where the sign-in appears under subscriptions with hint "ChatGPT".
    • Pasting a chatgpt_oauth value through the existing PUT /provider-keys is refused as invalid.
  • Refresh: refreshChatGptAccess(userId, ring) in packages/service/src/accounts/chatgpt-credentials.ts.
    • It runs in one transaction under SELECT … FOR UPDATE (withProviderKeyLocked), with OpenAI's token endpoint called inside the lock.
    • It hands out the stored access token while it has more than 10 minutes left. Otherwise it renews: issued client_id, resource, no scope.
    • On success it replaces the access token, expiry, scopes and refresh token together, plus the ID token when one is returned.
    • A second concurrent caller waits on the lock, then reads the renewed token without calling OpenAI.
    • A terminal refresh error (invalid_grant, refresh_token_reused, … invalid_client, per OpenAI's errors guide) marks the row invalid and keeps it. A 5xx, a network failure or a malformed answer changes nothing.
  • Revoke on delete: DELETE /api/account/provider-keys?provider=openai&kind=chatgpt_oauth locks the row first. It reads revocation_endpoint from OpenAI's discovery document and POSTs token, token_type_hint=refresh_token and client_id. On network failures and 5xx it retries twice with backoff. The row is deleted either way. When OpenAI didn't confirm the revocation, the answer carries a new optional revocationUnconfirmed: true, as the sessions guide asks.
  • ID token verified on save (review follow-up): a save checks the ID token against OpenAI's JWKS, read through discovery, cached for an hour, and read again for an unknown kid. It checks the RS256 signature, the issuer, that the audience contains clientId, the expiry (with 5 minutes of leeway), and that sub equals accountId. A failure is refused as the new unverified_sign_in; keys that can't be read answer unreachable. The nonce check stays on the desktop (P19).
  • Also from review:
    • The stored expiry is the earlier of expires_in and the access token's exp claim.
    • A renewal that loses the plan scope marks the row invalid.
    • A revocation endpoint off auth.openai.com is never sent the token.
  • Never logged or returned: no token, code, or OpenAI refusal text is logged or answered. A refusal is read only for its OAuth error code.

Tests

  • provider-key-store.test.ts:
    • A sign-in is kept as its own kind with its expiry, and resealed under its lock only from the seal it was read with.
    • Another account can neither read nor reseal it.
    • The host id is minted once, belongs to its own account, survives concurrent first reads, and outlives the sign-in.
  • New chatgpt-credentials.test.ts, against a fake OpenAI authorization server that rotates refresh tokens and refuses a reused one:
    • A fresh token is served without a call, and a due one is renewed and stored.
    • Two concurrent refreshes serialize. OpenAI sees the refresh token once and both callers get the same new access token. I checked that this test fails when the lock is removed: the second caller then gets refresh_token_reused.
    • A terminal error marks the row invalid and is never retried; a 5xx changes nothing.
    • Accounts are isolated.
    • Revocation sends the right form and then deletes the row; a failing revocation is retried, then deleted and reported unconfirmed (driven on the TestClock).
  • provider-keys-procedures.test.ts, for the routes:
    • A save is answered as a hint only. No token appears in the answer, the logs, or any stored column.
    • The refusals listed above.
    • The host id is stable and per-account.
    • DELETE revokes, then answers revocationUnconfirmed when OpenAI refuses.
  • The wire JSON-schema goldens were regenerated under LUKE_UPDATE_FIXTURES=1. The only differences are the new kind literal, the optional revocationUnconfirmed, and the two new schemas.

Checks

  • pnpm check:affected did not complete cleanly in the sandbox:
    • It first caught a knip finding, which is fixed.
    • Its combined typecheck was OOM-killed (137), as on P04.
    • The features suite hit 5 s timeouts in untouched tests (workspace-client, a few golden cases) after an 18-minute run under memory pressure.
  • Run separately, these all passed:
    • lint, oxlint, knip and discarded-effect
    • scripts/repository-checks.sh, check:migrations and check:contractions
    • typechecks for database, features, service, host, panel and web (web with a larger heap)
    • the 15 touched test files (525 tests)
  • CI gates the rest.

The migration is additive: a new table and a nullable column, hardened with IF NOT EXISTS and a drop-then-add foreign key.

The developer has waived docs/WORKFLOW.md's Preview runs for this rollout until the cohort. This PR merges on green once it has been restacked onto main, with no Preview run. PRIVACY.md is unchanged because nothing exposes the sign-in to users yet; P19 adds the disclosure along with the Settings UI.

Note: P16's probe (#4161), which this follows for the token exchange and refresh, is still open; I read it from its branch.

Made with Luke


Devin Review