Goal
OAuth for tools and MCP servers (N9b, N9c) needs somewhere durable and safe to keep access and refresh tokens, keyed by provider and by who owns the credential (the agent itself, or one signed-in user). This ticket adds that store as a new optional part of AgentStore, with in-memory, SQLite and Workers KV implementations that encrypt tokens at rest, plus the shared types. It is the first of three tickets for the AUDIT-2 matrix row "OAuth for tools and connections" (us ❌, eve ✅). Nothing user-visible signs in yet; N9b adds the flow.
Current state
AgentStore (src/storage/agentStore.ts:32-39) has three optional parts: sessions?: SessionStore, checkpoints?: CheckpointStore, approvals?: ApprovalStore. memoryStore() (line 91) returns Required<AgentStore> built from in-memory parts.
SqliteStore (src/storage/sqlite/SqliteStore.ts:48) exposes sessions, checkpoints, approvals and connection; constructor (path: string, options: SqliteStoreOptions = {}) (line 64); member stores in src/storage/sqlite/stores.ts (SqliteApprovalStore line 133, upsert(table, key) helper line 40). Migrations: MIGRATIONS in src/storage/sqlite/migrations.ts:11, "Append new entries; never edit shipped ones" (line 6); three entries today, the last creates memory_items (scope_key TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at INTEGER NOT NULL).
KVStore implements Required<AgentStore> (src/deploy/kvStore.ts:105), keys <prefix>sessions/, checkpoints/, approvals/ (constructor line 110, options { prefix?, ttl?, historyLimit? }). R2 exports it as @lousho/build-ai-agent/kv and adds fileStore(dir).
createAgent({ store }) reads store.checkpoints, store.approvals, store.sessions (src/createAgent.ts:635, 663, 737).
- Encryption today:
EncryptionUtils in src/security/crypto.ts:40 (AES-GCM with a PBKDF2-SHA-256 key derived per encryption, 100,000 iterations, a 16-byte salt and 16-byte IV each time, lines 59-116). It is designed for passwords, not for a stored 256-bit key, and costs one PBKDF2 run per call.
- Grep
-i "oauth|tokenStore|getToken|refresh_token" in src/: no hits.
Scope
In:
- New module
src/oauth/ (no node:* import; Web Crypto only), types exported from the root next to AgentStore:
export type CredentialOwner = 'app' | 'user';
export interface OAuthToken {
accessToken: string;
refreshToken?: string;
tokenType?: string; // usually 'Bearer'
expiresAt?: number; // ms since epoch
scope?: string;
}
/** Who a stored credential belongs to. A user credential is keyed by the principal's issuer and id (N10a). */
export type TokenOwner = { owner: 'app' } | { owner: 'user'; principalId: string; issuer?: string };
export interface OAuthTokenStore {
get(provider: string, owner: TokenOwner): Promise<OAuthToken | undefined>;
set(provider: string, owner: TokenOwner, token: OAuthToken): Promise<void>;
delete(provider: string, owner: TokenOwner): Promise<void>;
/** Short-lived sign-in state (PKCE verifier and context), single use: `take` returns and deletes it. */
putPending(state: string, value: PendingSignIn, ttlMs: number): Promise<void>;
takePending(state: string): Promise<PendingSignIn | undefined>;
/** A client registered with an authorization server (dynamic client registration, used by N9c), per provider. */
getClient(provider: string): Promise<Record<string, unknown> | undefined>;
setClient(provider: string, client: Record<string, unknown>): Promise<void>;
}
export interface PendingSignIn { provider: string; owner: TokenOwner; codeVerifier?: string; redirectUri: string; approvalId?: string; createdAt: number; data?: Record<string, unknown> }
export function tokenStoreKey(provider: string, owner: TokenOwner): string; // stable, injective, URL-safe
AgentStore.tokens?: OAuthTokenStore (new optional part, documented like the other three).
- Key format (
tokenStoreKey): <provider>|app or <provider>|user|<issuer>|<principalId>, each component percent-encoded, so a|b cannot collide with another owner. Registered clients use <provider>|client in the same table or key space, encrypted like tokens (a client secret may be in it). Provider names follow tool-name rules (^[A-Za-z0-9_-]{1,64}$), validated with a ConfigurationError.
- Encryption at rest, decided: AES-256-GCM through
crypto.subtle, a 32-byte key given as base64 (tokenKey option, else the LOUSHO_TOKEN_KEY environment variable where process exists), a fresh 12-byte IV per write, and the record key as additional authenticated data (so a ciphertext copied to another row fails to decrypt). Stored form: v1.<base64 iv>.<base64 ciphertext>. Pending sign-ins are encrypted the same way. Not reusing EncryptionUtils because its per-call PBKDF2 is meant for passwords and costs about 100 ms per token read. Put the cipher in src/oauth/tokenCipher.ts.
- Implementations:
memoryStore() gains tokens (plain objects in a Map, no encryption: process memory; pending entries expire by ttlMs).
SqliteStore gains tokens: migration 4 appended to MIGRATIONS: CREATE TABLE oauth_tokens (key TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE oauth_pending (state TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL);. takePending deletes in the same transaction it reads (single use). SqliteStoreOptions.tokenKey?: string. prune() also deletes expired pending rows.
KVStore gains tokens: keys <prefix>oauth/tokens/<key> and <prefix>oauth/pending/<state> (pending written with expirationTtl). KVStoreOptions.tokenKey?: string. KV has no transactions: takePending is get-then-delete; document that the state value is single use only as far as KV's eventual consistency allows, and that the 128-bit random state makes reuse useless to an attacker who does not have it.
- If R2's
fileStore(dir) is on main when you start, give it tokens too (<dir>/oauth/, one encrypted file per key, written atomically like the other file stores); otherwise leave a one-line follow-up in the pull request.
- A persistent store (
SqliteStore, KVStore, file) throws ConfigurationError code LOUSHO_TOKEN_KEY_MISSING from its first tokens.set or putPending when no key is configured; reads with no key return undefined (so an agent that never uses OAuth needs no key). A wrong key on read throws LOUSHO_TOKEN_DECRYPT_FAILED naming the provider, not the token.
- Helper
generateTokenKey(): string (32 random bytes, base64) exported from the root; the docs also give the shell form node -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))".
- Errors:
LOUSHO_TOKEN_KEY_MISSING, LOUSHO_TOKEN_DECRYPT_FAILED in src/utils/errorCodes.ts, documented in a new ## OAuth section appended at the end of docs/errors.md.
- Docs: new page
docs/oauth.md with, for now, two sections that N9b and N9c extend: "Credential owners" (app vs user, user credentials need a principal from route auth, docs/auth.md) and "Token storage" (the tokens part of each store, the key, rotation: changing the key makes old tokens unreadable and users sign in again; nothing ever logs a token). Mention AgentStore.tokens in docs/sessions.md ## Stores (line 184; one sentence, no new heading).
- CHANGELOG entry;
npm run docs:llms.
Out:
- The sign-in flow,
ctx.getToken(), pausing a run and the callback route: N9b.
- MCP server OAuth: N9c.
- Key rotation tooling (re-encrypting with a new key).
Acceptance criteria
Live test
None: this ticket spends nothing.
Dependencies
None to start. N9b and N9c depend on this ticket. Conflicts: N15 also appends a migration to MIGRATIONS (whichever merges second renumbers its entry; never edit a shipped one) and touches SqliteStore; R2 changes kvStore.ts exports and adds fileStore.
Notes for the implementer
- The store must never put a token into an error message, a log line or an exception
cause; tests should assert the message of every thrown error does not contain the token.
TokenOwner for users takes plain strings rather than N10a's Principal, so this ticket has no dependency on N10a; N9b maps principal.issuer and principal.id onto it.
- Base64 decode of
tokenKey must yield exactly 32 bytes; anything else is a ConfigurationError at construction.
- Keep the cipher and key parsing free of
node:* so KVStore keeps working in the Worker bundle.
Round 2 ticket N9a. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; put Closes #<this issue> in it.
Goal
OAuth for tools and MCP servers (N9b, N9c) needs somewhere durable and safe to keep access and refresh tokens, keyed by provider and by who owns the credential (the agent itself, or one signed-in user). This ticket adds that store as a new optional part of
AgentStore, with in-memory, SQLite and Workers KV implementations that encrypt tokens at rest, plus the shared types. It is the first of three tickets for the AUDIT-2 matrix row "OAuth for tools and connections" (us ❌, eve ✅). Nothing user-visible signs in yet; N9b adds the flow.Current state
AgentStore(src/storage/agentStore.ts:32-39) has three optional parts:sessions?: SessionStore,checkpoints?: CheckpointStore,approvals?: ApprovalStore.memoryStore()(line 91) returnsRequired<AgentStore>built from in-memory parts.SqliteStore(src/storage/sqlite/SqliteStore.ts:48) exposessessions,checkpoints,approvalsandconnection; constructor(path: string, options: SqliteStoreOptions = {})(line 64); member stores insrc/storage/sqlite/stores.ts(SqliteApprovalStoreline 133,upsert(table, key)helper line 40). Migrations:MIGRATIONSinsrc/storage/sqlite/migrations.ts:11, "Append new entries; never edit shipped ones" (line 6); three entries today, the last createsmemory_items (scope_key TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at INTEGER NOT NULL).KVStore implements Required<AgentStore>(src/deploy/kvStore.ts:105), keys<prefix>sessions/,checkpoints/,approvals/(constructor line 110, options{ prefix?, ttl?, historyLimit? }). R2 exports it as@lousho/build-ai-agent/kvand addsfileStore(dir).createAgent({ store })readsstore.checkpoints,store.approvals,store.sessions(src/createAgent.ts:635,663,737).EncryptionUtilsinsrc/security/crypto.ts:40(AES-GCM with a PBKDF2-SHA-256 key derived per encryption, 100,000 iterations, a 16-byte salt and 16-byte IV each time, lines 59-116). It is designed for passwords, not for a stored 256-bit key, and costs one PBKDF2 run per call.-i "oauth|tokenStore|getToken|refresh_token"insrc/: no hits.Scope
In:
src/oauth/(nonode:*import; Web Crypto only), types exported from the root next toAgentStore:AgentStore.tokens?: OAuthTokenStore(new optional part, documented like the other three).tokenStoreKey):<provider>|appor<provider>|user|<issuer>|<principalId>, each component percent-encoded, soa|bcannot collide with another owner. Registered clients use<provider>|clientin the same table or key space, encrypted like tokens (a client secret may be in it). Provider names follow tool-name rules (^[A-Za-z0-9_-]{1,64}$), validated with aConfigurationError.crypto.subtle, a 32-byte key given as base64 (tokenKeyoption, else theLOUSHO_TOKEN_KEYenvironment variable whereprocessexists), a fresh 12-byte IV per write, and the record key as additional authenticated data (so a ciphertext copied to another row fails to decrypt). Stored form:v1.<base64 iv>.<base64 ciphertext>. Pending sign-ins are encrypted the same way. Not reusingEncryptionUtilsbecause its per-call PBKDF2 is meant for passwords and costs about 100 ms per token read. Put the cipher insrc/oauth/tokenCipher.ts.memoryStore()gainstokens(plain objects in aMap, no encryption: process memory; pending entries expire byttlMs).SqliteStoregainstokens: migration 4 appended toMIGRATIONS:CREATE TABLE oauth_tokens (key TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE oauth_pending (state TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL);.takePendingdeletes in the same transaction it reads (single use).SqliteStoreOptions.tokenKey?: string.prune()also deletes expired pending rows.KVStoregainstokens: keys<prefix>oauth/tokens/<key>and<prefix>oauth/pending/<state>(pending written withexpirationTtl).KVStoreOptions.tokenKey?: string. KV has no transactions:takePendingis get-then-delete; document that the state value is single use only as far as KV's eventual consistency allows, and that the 128-bit random state makes reuse useless to an attacker who does not have it.fileStore(dir)is onmainwhen you start, give ittokenstoo (<dir>/oauth/, one encrypted file per key, written atomically like the other file stores); otherwise leave a one-line follow-up in the pull request.SqliteStore,KVStore, file) throwsConfigurationErrorcodeLOUSHO_TOKEN_KEY_MISSINGfrom its firsttokens.setorputPendingwhen no key is configured; reads with no key returnundefined(so an agent that never uses OAuth needs no key). A wrong key on read throwsLOUSHO_TOKEN_DECRYPT_FAILEDnaming the provider, not the token.generateTokenKey(): string(32 random bytes, base64) exported from the root; the docs also give the shell formnode -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))".LOUSHO_TOKEN_KEY_MISSING,LOUSHO_TOKEN_DECRYPT_FAILEDinsrc/utils/errorCodes.ts, documented in a new## OAuthsection appended at the end ofdocs/errors.md.docs/oauth.mdwith, for now, two sections that N9b and N9c extend: "Credential owners" (app vs user, user credentials need a principal from route auth, docs/auth.md) and "Token storage" (thetokenspart of each store, the key, rotation: changing the key makes old tokens unreadable and users sign in again; nothing ever logs a token). MentionAgentStore.tokensindocs/sessions.md## Stores(line 184; one sentence, no new heading).npm run docs:llms.Out:
ctx.getToken(), pausing a run and the callback route: N9b.Acceptance criteria
src/oauth/tokenStore.contract.ts: a shared vitest contract (in the style ofsrc/memory/providerContract.ts) run against the memory, SQLite and KV stores (KV over an in-memoryKVBindingfake as insrc/deploy/kvStore.test.ts): set, get, delete;setClient/getClientround trip; app and user owners and the client record never collide; issuer is part of a user key;takePendingreturns once thenundefined; an expired pending entry is gone.src/oauth/tokenCipher.test.ts: round trip; a different key fails withLOUSHO_TOKEN_DECRYPT_FAILED; a ciphertext moved to another key (AAD) fails; two writes of the same token produce different ciphertexts.oauth_tokens.payloadand the raw KV value and asserts the access and refresh token strings do not appear in them.user_versionopens 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.docs/oauth.mdis new;docs/errors.mdhas the appended## OAuthsection; snippets passnpm run docs:verify-snippets -- --skip-build; CHANGELOG;npm run docs:llms; every check in BRIEF-2.md "Verification" passes.oauth(English, Arabic, navigation for both languages,PAGESentry) and the new## OAuthsection at the end oferrors.Live test
None: this ticket spends nothing.
Dependencies
None to start. N9b and N9c depend on this ticket. Conflicts: N15 also appends a migration to
MIGRATIONS(whichever merges second renumbers its entry; never edit a shipped one) and touchesSqliteStore; R2 changeskvStore.tsexports and addsfileStore.Notes for the implementer
cause; tests should assert the message of every thrown error does not contain the token.TokenOwnerfor users takes plain strings rather than N10a'sPrincipal, so this ticket has no dependency on N10a; N9b mapsprincipal.issuerandprincipal.idonto it.tokenKeymust yield exactly 32 bytes; anything else is aConfigurationErrorat construction.node:*soKVStorekeeps working in the Worker bundle.Round 2 ticket
N9a. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; putCloses #<this issue>in it.