Skip to content

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

Description

@LinuxDevil

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

  • src/oauth/tokenStore.contract.ts: a shared vitest contract (in the style of src/memory/providerContract.ts) run against the memory, SQLite and KV stores (KV over an in-memory KVBinding fake as in src/deploy/kvStore.test.ts): set, get, delete; setClient / getClient round trip; app and user owners and the client record never collide; issuer is part of a user key; takePending returns once then undefined; an expired pending entry is gone.
  • src/oauth/tokenCipher.test.ts: round trip; a different key fails with LOUSHO_TOKEN_DECRYPT_FAILED; a ciphertext moved to another key (AAD) fails; two writes of the same token produce different ciphertexts.
  • A SQLite test reads the raw oauth_tokens.payload and the raw KV value and asserts the access and refresh token strings do not appear in them.
  • A migration test: a database created at the previous user_version 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.
  • docs/oauth.md is new; docs/errors.md has the appended ## OAuth section; snippets pass npm run docs:verify-snippets -- --skip-build; CHANGELOG; npm run docs:llms; every check in BRIEF-2.md "Verification" passes.
  • The pull request lists for the docs site (G9): new page oauth (English, Arabic, navigation for both languages, PAGES entry) and the new ## OAuth section at the end of errors.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    model:opusRun loop, security or API design; needs Opusround-2Round 2 plan ticketwave-3Round 2, wave 3

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions