Skip to content

[N9a] OAuth token store on the AgentStore, encrypted, with credential owners - #312

Merged
LinuxDevil merged 5 commits into
mainfrom
lou-n9a-oauth-token-store
Oct 2, 2026
Merged

LinuxDevil merged 5 commits into
mainfrom
lou-n9a-oauth-token-store

Conversation

@LinuxDevil

@LinuxDevil LinuxDevil commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #246

What

AgentStore gains a fourth optional part, tokens (OAuthTokenStore), implemented by every shipped store:

Store tokens At rest
memoryStore() MemoryTokenStore (Maps, copies in and out, pending entries expire by ttlMs) process memory only, not encrypted (per the ticket)
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 claim sealed
SqliteStore(path, { tokenKey }) migration 4: oauth_tokens, oauth_pending; takePending reads and deletes in one BEGIN IMMEDIATE transaction; prune() also deletes expired pending rows (PruneResult.oauthPending) sealed
KVStore(kv, { tokenKey }) <prefix>oauth/tokens/<key>, <prefix>oauth/pending/<state> with expirationTtl (min 60 s; the record's own expiresAt is exact) sealed
  • Interface: the ticket's get / set / delete / putPending / takePending / getClient / setClient, plus list({ provider?, owner? }), which returns metadata only (provider, owner, tokenType, expiresAt, scope, hasRefreshToken, updatedAt).
  • tokenStoreKey(provider, owner): <provider>|app or <provider>|user|<issuer>|<principalId>, issuer and id percent-encoded (absent issuer = empty component), so it is injective; clients are <provider>|client in 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 in takePending is just "not found", since it comes from a callback URL).
  • Shared encrypted implementation SealedTokenStore (src/oauth/sealedTokenStore.ts) over a tiny per-store backend of opaque strings, so validation, encryption and expiry are written once. No node:* anywhere in src/oauth/; the /kv bundle test (src/deploy/kv.test.ts) still passes.
  • Cipher src/oauth/tokenCipher.ts: AES-256-GCM via crypto.subtle, 32-byte key from tokenKey or LOUSHO_TOKEN_KEY (no default, nothing derived), fresh 12-byte IV per write, record key as AAD (pending:<state> for sign-ins), stored as v1.<b64 iv>.<b64 ciphertext>. Key strings are validated at store construction (exactly 32 bytes of canonical base64, ConfigurationError that never echoes the key). generateTokenKey() exported from the root.
  • Key rotation: tokenKey takes one key or several (newest first), LOUSHO_TOKEN_KEY takes a comma-separated list; writes use the first, reads try each. No re-encrypt-everything tool (out of scope); documented.
  • No key: reads that find nothing return undefined (an agent that never uses OAuth needs no key). The first set / setClient / putPending throws LOUSHO_TOKEN_KEY_MISSING. Deviation, on purpose: reading a record that exists without a key also throws LOUSHO_TOKEN_KEY_MISSING instead of returning undefined, so a missing key is never mistaken for "signed out".
  • Wrong key / changed record / record moved to another key: LOUSHO_TOKEN_DECRYPT_FAILED, naming the provider (or "pending sign-in"), never the token, no cause.
  • The generated Cloudflare Worker (workerStore() in src/deploy/runtime.worker.ts) passes env.LOUSHO_TOKEN_KEY (a Worker secret) to KVStore, since Workers have no process.env. KVBinding gains an optional list() (only tokens.list() needs it; a clear ConfigurationError otherwise); KVListOptions / KVListResult are exported from /kv.
  • src/utils/errorCodes.test.ts: the "codes used in src/" scan now ignores the env var LOUSHO_TOKEN_KEY, which shares the new TOKEN area prefix.

Credential owners and route auth (#249)

TokenOwner is 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; its Principal has id and issuer?, so N9b maps a principal as { owner: 'user', principalId: principal.id, issuer: principal.issuer }. The issuer is part of the key (same id from another issuer is another owner), matching Principal'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

Requirement Test
Authenticated cipher, application key, no default key 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..."
Missing / malformed key is a clear configuration error 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"
Fresh random nonce per record tokenCipher.test.ts "two writes of the same token produce different ciphertexts"
Record binding (AAD) 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"
Wrong key / tampering 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)"
Not in plaintext at rest tokenStore.test.ts "stores no access token, refresh token, PKCE verifier or client secret in plaintext" (raw files, raw SQLite rows, raw KV values)
Never in events, transcripts, traces, checkpoints, cassettes, logs 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, fileTraceExporter files with captureContent: true, a recordReplay cassette, console spies, store files)
Never in error messages contract "never puts a token into an error it throws" (x4 stores); tokenLeak.test.ts "a failed token read (wrong key) surfaces an error that names the provider, not the token" (through a full run)
Key rotation 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 absent
Listing returns metadata only contract "lists metadata only, filtered by provider and owner, without clients" (x4)
Delete supported contract "sets, gets, replaces and deletes a token" (x4)

Acceptance criteria

  • src/oauth/tokenStore.contract.ts, run against memory, file, SQLite and KV (KV over an in-memory fake with paged list()): set/get/delete, client round trip, app/user/client never collide, issuer in the user key, takePending once, expired pending gone.
  • src/oauth/tokenCipher.test.ts: round trip, different key, AAD move, two writes differ.
  • Raw SQLite oauth_tokens.payload and raw KV value (and raw files) do not contain the token strings.
  • Migration test (database at user_version 3 opens and gains both tables); prune() deletes expired pending rows.
  • LOUSHO_TOKEN_KEY_MISSING on first write without a key; no error on reads without a key (of records that do not exist; see the deviation above).
  • docs/oauth.md new; ## OAuth appended at the end of docs/errors.md (after main's new ## Auth); snippets pass; CHANGELOG; npm run docs:llms.
  • fileStore was on main: it has tokens too.

Docs-site follow-up (G9)

  • New page: oauth (docs/oauth.md: ## Credential owners, ## Token storage). Needs English, Arabic, navigation for both languages and a PAGES entry.
  • New section: ## OAuth at the end of errors (two ### codes), after the ## Auth section 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.
  • Edited only (no heading changes): sessions (one paragraph in ## Stores, the prune() comment and bullet), cloudflare-workers (tokenKey in the KVStore signature, one row in the keys table), api-overview (the AgentStore row), README docs table (one row).

Notes

  • An outside account (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).
  • A KV key is at most 512 bytes; a very long percent-encoded principal id plus prefix could exceed it (the put would fail loudly). Owner parts are capped at 1024 characters.

Verification (after merging the latest origin/main, f8c9d3e #313)

npx tsc --noEmit                                    exit 0
npm run lint                                        exit 0 (0 warnings)
npm run build                                       exit 0
npm run build --workspace=packages/create-lousho-agent   exit 0
npm run test:types                                  exit 0
npm run docs:verify-snippets -- --skip-build        all 225 snippet(s) type-check against src/; 8 also run cleanly
npm run docs:llms:check                             exit 0
npm run pack-smoke                                  [pack-smoke] all checks passed
npm run test:coverage                               250 files passed, 1 skipped; 3693 tests passed, 7 skipped
npm run fallow                                      exit 0; dead exports 0.0%; 0 above threshold; maintainability 89.6
npm run typecheck --workspace apps/agent-forge      exit 0
npm run typecheck:server --workspace apps/agent-forge    exit 0
npm run test --workspace apps/agent-forge -- --run  119 passed
npm run test:server --workspace apps/agent-forge    133 passed

Earlier full runs on this branch (before the last sync) hit load flakes in files this ticket does not touch: SubprocessSandbox.test.ts Docker integration ("no such container", the daemon is shared with other agents; alone 13 passed) and 5 s timeouts in NodeWorkspace.test.ts shell tests (alone 26 passed, 1 skipped).

Live test spend: none (this ticket makes no model calls).

🤖 Generated with Claude Code

LinuxDevil and others added 5 commits October 2, 2026 21:13
… 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
@LinuxDevil
LinuxDevil merged commit 181e44d into main Oct 2, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[N9a] OAuth token store on the AgentStore, encrypted, with credential owners

1 participant