[N9a] OAuth token store on the AgentStore, encrypted, with credential owners - #312
Merged
Merged
Conversation
… owners AgentStore gains an optional `tokens` part (OAuthTokenStore): tokens per provider and owner (app, or user by principalId and issuer), metadata-only list(), single-use pending sign-ins and registered clients. memoryStore() keeps them in memory; fileStore, SqliteStore (migration 4) and KVStore seal every record with AES-256-GCM via Web Crypto under an application-supplied key (tokenKey / LOUSHO_TOKEN_KEY, no default), a fresh IV per write and the record key as AAD. Several keys rotate. New codes LOUSHO_TOKEN_KEY_MISSING and LOUSHO_TOKEN_DECRYPT_FAILED; new page docs/oauth.md. Closes #246 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tore # Conflicts: # CHANGELOG.md # docs/errors.md # llms-full.txt
This was referenced Oct 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #246
What
AgentStoregains a fourth optional part,tokens(OAuthTokenStore), implemented by every shipped store:tokensmemoryStore()MemoryTokenStore(Maps, copies in and out, pending entries expire byttlMs)fileStore(dir, { tokenKey })<dir>/oauth/tokens/<sha256 of key>.json({ key, payload }),<dir>/oauth/pending/<state>.json({ expiresAt, payload }), atomic writes, pending taken with an exclusive-create claimSqliteStore(path, { tokenKey })oauth_tokens,oauth_pending;takePendingreads and deletes in oneBEGIN IMMEDIATEtransaction;prune()also deletes expired pending rows (PruneResult.oauthPending)KVStore(kv, { tokenKey })<prefix>oauth/tokens/<key>,<prefix>oauth/pending/<state>withexpirationTtl(min 60 s; the record's ownexpiresAtis exact)get/set/delete/putPending/takePending/getClient/setClient, pluslist({ provider?, owner? }), which returns metadata only (provider, owner, tokenType, expiresAt, scope, hasRefreshToken, updatedAt).tokenStoreKey(provider, owner):<provider>|appor<provider>|user|<issuer>|<principalId>, issuer and id percent-encoded (absent issuer = empty component), so it is injective; clients are<provider>|clientin the same key space. Provider names^[A-Za-z0-9_-]{1,64}$(ConfigurationError), owner parts 1-1024 chars,state^[A-Za-z0-9_-]{16,128}$(a malformed state intakePendingis just "not found", since it comes from a callback URL).SealedTokenStore(src/oauth/sealedTokenStore.ts) over a tiny per-store backend of opaque strings, so validation, encryption and expiry are written once. Nonode:*anywhere insrc/oauth/; the/kvbundle test (src/deploy/kv.test.ts) still passes.src/oauth/tokenCipher.ts: AES-256-GCM viacrypto.subtle, 32-byte key fromtokenKeyorLOUSHO_TOKEN_KEY(no default, nothing derived), fresh 12-byte IV per write, record key as AAD (pending:<state>for sign-ins), stored asv1.<b64 iv>.<b64 ciphertext>. Key strings are validated at store construction (exactly 32 bytes of canonical base64,ConfigurationErrorthat never echoes the key).generateTokenKey()exported from the root.tokenKeytakes one key or several (newest first),LOUSHO_TOKEN_KEYtakes a comma-separated list; writes use the first, reads try each. No re-encrypt-everything tool (out of scope); documented.undefined(an agent that never uses OAuth needs no key). The firstset/setClient/putPendingthrowsLOUSHO_TOKEN_KEY_MISSING. Deviation, on purpose: reading a record that exists without a key also throwsLOUSHO_TOKEN_KEY_MISSINGinstead of returningundefined, so a missing key is never mistaken for "signed out".LOUSHO_TOKEN_DECRYPT_FAILED, naming the provider (or "pending sign-in"), never the token, nocause.workerStore()insrc/deploy/runtime.worker.ts) passesenv.LOUSHO_TOKEN_KEY(a Worker secret) toKVStore, since Workers have noprocess.env.KVBindinggains an optionallist()(onlytokens.list()needs it; a clearConfigurationErrorotherwise);KVListOptions/KVListResultare exported from/kv.src/utils/errorCodes.test.ts: the "codes used in src/" scan now ignores the env varLOUSHO_TOKEN_KEY, which shares the newTOKENarea prefix.Credential owners and route auth (#249)
TokenOwneris plain strings, with no dependency on the auth module:{ owner: 'app' } | { owner: 'user'; principalId: string; issuer?: string }. Route auth (#249) merged while this was in progress; itsPrincipalhasidandissuer?, so N9b maps a principal as{ owner: 'user', principalId: principal.id, issuer: principal.issuer }. The issuer is part of the key (sameidfrom another issuer is another owner), matchingPrincipal's own doc comment. docs/oauth.md links to docs/auth.md.EncryptionUtils: not reused
EncryptionUtils(src/security/crypto.ts) derives an AES-GCM key from a password with PBKDF2 (100,000 iterations) on every call, binds no additional data (a ciphertext can be swapped between rows undetected), uses a non-standard 16-byte GCM IV and a binary salt+IV framing: it is a password-encryption helper, slow per token read and missing the record binding, so a small dedicated Web Crypto helper with a raw 256-bit key and AAD is the safer choice.Security requirements and the tests that cover them
tokenCipher.test.ts"has no default key...", "round-trips a record";tokenStore.test.ts"throws LOUSHO_TOKEN_KEY_MISSING on the first write without a key" (x file, SQLite, KV), "uses LOUSHO_TOKEN_KEY..."tokenCipher.test.ts"refuses a key that is not exactly 32 bytes of base64, without echoing it";tokenStore.test.ts"refuses a malformed tokenKey when the store is built" (x3);runtime.worker.test.ts"encrypts OAuth tokens with the LOUSHO_TOKEN_KEY secret of env"tokenCipher.test.ts"two writes of the same token produce different ciphertexts"tokenCipher.test.ts"a ciphertext moved to another record key (AAD) fails";tokenStore.test.ts"a sealed row copied onto another owner does not decrypt", "KV: a sealed value copied onto another key does not decrypt either"tokenCipher.test.ts"a different key fails...", "a changed or malformed record fails the same way";tokenStore.test.ts"throws LOUSHO_TOKEN_DECRYPT_FAILED naming the provider, never the token (SQLite file)"tokenStore.test.ts"stores no access token, refresh token, PKCE verifier or client secret in plaintext" (raw files, raw SQLite rows, raw KV values)tokenLeak.test.ts"a tool that uses a stored token leaks it into no event, transcript, checkpoint, trace, cassette, log or file" (sentinel tokens;onEvent, session transcript, every saved checkpoint,fileTraceExporterfiles withcaptureContent: true, arecordReplaycassette, console spies, store files)tokenLeak.test.ts"a failed token read (wrong key) surfaces an error that names the provider, not the token" (through a full run)tokenCipher.test.ts"rotates: writes with the first key, reads with any listed key", "reads LOUSHO_TOKEN_KEY (comma-separated, newest first)"; SQLite file test reopens with[newKey, oldKey]; re-encrypt tool documented as absentAcceptance criteria
src/oauth/tokenStore.contract.ts, run against memory, file, SQLite and KV (KV over an in-memory fake with pagedlist()): set/get/delete, client round trip, app/user/client never collide, issuer in the user key,takePendingonce, expired pending gone.src/oauth/tokenCipher.test.ts: round trip, different key, AAD move, two writes differ.oauth_tokens.payloadand raw KV value (and raw files) do not contain the token strings.user_version3 opens and gains both tables);prune()deletes expired pending rows.LOUSHO_TOKEN_KEY_MISSINGon first write without a key; no error on reads without a key (of records that do not exist; see the deviation above).docs/oauth.mdnew;## OAuthappended at the end ofdocs/errors.md(after main's new## Auth); snippets pass; CHANGELOG;npm run docs:llms.tokenstoo.Docs-site follow-up (G9)
oauth(docs/oauth.md:## Credential owners,## Token storage). Needs English, Arabic, navigation for both languages and aPAGESentry.## OAuthat the end oferrors(two###codes), after the## Authsection that [N10a] Route auth: jwt(), oidc(), basic() in an ordered list, principal per run #249 added; plus one new row in its "Find a code by area" table.sessions(one paragraph in## Stores, theprune()comment and bullet),cloudflare-workers(tokenKeyin theKVStoresignature, one row in the keys table),api-overview(theAgentStorerow), README docs table (one row).Notes
aetherxeg-source) commented on [N9a] OAuth token store on the AgentStore, encrypted, with credential owners #246 suggesting a credential generation counter to stop a restored old row from becoming current again. It is not in this ticket's scope and was not implemented; AAD binds a ciphertext to its record key, not to a point in time, so restoring an old backup of the same row restores the old token. Worth a look in N9b (refresh/revoke).Verification (after merging the latest origin/main, f8c9d3e #313)
Earlier full runs on this branch (before the last sync) hit load flakes in files this ticket does not touch:
SubprocessSandbox.test.tsDocker integration ("no such container", the daemon is shared with other agents; alone 13 passed) and 5 s timeouts inNodeWorkspace.test.tsshell tests (alone 26 passed, 1 skipped).Live test spend: none (this ticket makes no model calls).
🤖 Generated with Claude Code