feat(auth): Personal Access Tokens — agent-runtime authentication (#49) #135
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!135
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "49-personal-access-tokens"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #49. First building block in epic #47 (agent-driven interaction): external runtimes can now authenticate to Carol's API without a session cookie.
Token format
lookup_id: 8 random bytes → 16 hex chars.UNIQUE-indexed; one row lookup per authentication.secret: 32 random bytes → 43 base64url chars. Hashed with argon2id (same primitive as passwords) and stored intoken_hash.ADR-0021 covers the why: argon2id is non-deterministic (can't index by it directly), so the split-id pattern is what makes index-fast lookup compatible with the "hash like passwords" requirement. Same shape as
ghp_<short>....What lands
DB (migration 009) —
personal_access_tokens(id, user_id, token_lookup_id UNIQUE, token_hash, name, scopes, created_at, last_used_at, expires_at, revoked_at), indexed(user_id, revoked_at). Repository withcreate / findByLookupId / findById / listActiveByUserId / touchLastUsed / revoke(soft-delete).Auth integration
lib/auth/pat.ts—generateToken / parseToken / verifyToken(full parse → row → expiry → revoke → argon2id verify; toucheslast_used_at).lib/auth/identity.ts—getAuthIdentity(req)returning{userId, method: 'session' | 'pat'}. Cookie wins always; bearer is only consulted when no cookie AND path is/api/*; bearer on a non-/api/*path is ignored.proxy.tsswitches togetAuthIdentity(the existing public-routes / unauthed-redirect /Cache-Control: private, no-storepolicy is preserved verbatim)./api/notes/*,/api/profile/*) switch togetAuthIdentity./api/account/tokens*and/api/auth/*stay ongetSessiondirectly.Token management is session-only by policy — a stolen PAT cannot list, create, or revoke tokens. Only an interactive sign-in can. Closes the breach-hiding angle where a compromised token would silently self-rotate.
API
GET /api/account/tokens— list active (no plaintext, no hash).POST /api/account/tokens— create. Response carries plaintext exactly once. Hard cap of 25 active per user.DELETE /api/account/tokens/[id]— revoke (soft-delete). Cross-user 404, never 403.Frontend (
/account/tokens) — server prefetch of the active-tokens list, hydrated intoTokensClient. TanStack Query (["account","tokens"]) + TanStack Form (name + optional datetime expiry). Plaintext rendered exactly once in aRevealBannerwith copy button; never re-fetched, never written to the cache./accountpage gains a Manage tokens → link.Security
.gitleaks.tomlgains a custom rule forcarol_pat_*so a leaked PAT trips both pre-commit and the CI scan.token_lookup_idliterals in PAT test fixtures (which would otherwise trip the upstreamgeneric-api-keyheuristic).Acceptance criteria
personal_access_tokenstable migration applies on startup, both engines. Migration 009. Dual-engine repository tests intests/db/personal-access-tokens.test.ts(SQLite locally + Postgres in CI viaTEST_POSTGRES_URL).POST /api/account/tokensresponse carriesplaintext;GET /api/account/tokensreturns the summary DTO (tests/dto/pat.test.tsasserts the summary has notokenHash/tokenLookupId/plaintextkeys;tests/api/account-tokens.test.tsasserts the list response has noplaintext).Authorization: Bearer <token>reaches authorised endpoints as that user; revoked → 401.tests/api/account-tokens.test.ts → DELETE → "revokes the token (subsequent Bearer use returns 401)".tests/api/account-tokens.test.ts → "A's PAT does not see B's data"(asserts/api/notesis empty under A's PAT after B creates a note); cross-userDELETE /api/account/tokens/[id]returns 404.describePerEngine. Local run: 398 passed | 91 skipped (skipped = Postgres legs; CI runs both).Local verification
npm run typecheckclean.npm run lintclean.npm test→ 398 passed, 91 skipped.npm run buildclean; new routes registered:/account/tokens,/api/account/tokens,/api/account/tokens/[id].Composes with
getAuthIdentityso PATs reach them. Renumbered around them during rebase (migration 009; ADR-0021).agentonly today) but no route enforces it yet. Enforcement lands with MCP.Out of scope (epic-#47 follow-ups)
/api/mcp— PAT now exists, the streamable-HTTP server is a separate ticket.scopesfield into arequireScope("agent")guard once MCP lands./api/account/tokensaccepting Bearer). Stays out until audit logging is in place to detect rotation-from-stolen-token.carol tokens createetc. Not in scope; the web UI is the source of truth today.Test plan
curl -H "Authorization: Bearer carol_pat_..." http://localhost:3000/api/notes— see your notes round-trip. Revoke and rerun — 401./accountwith onlyAuthorization: Bearer …(and no cookie) redirects to/login, not 401 — PAT is API-only.Adds a per-user, revocable token primitive so external agent runtimes (Claude Code, opencode, custom MCP clients) can authenticate to Carol's /api/* without holding a session cookie. Token format: carol_pat_<lookup_id>_<secret> - lookup_id: 8 random bytes (16 hex chars), UNIQUE-indexed for O(log n) row lookup. - secret: 32 random bytes (43 base64url chars), hashed with argon2id (same primitive as passwords). DB - 007_personal_access_tokens: (id, user_id, token_lookup_id UNIQUE, token_hash, name, scopes, created_at, last_used_at, expires_at, revoked_at). Index on (user_id, revoked_at). - PersonalAccessTokensRepository: create/findByLookupId/findById/ listActiveByUserId/touchLastUsed/revoke (soft-delete). Auth integration - lib/auth/pat.ts: generateToken / parseToken / verifyToken. - lib/auth/identity.ts: getAuthIdentity(req) returning {userId, method: session|pat}. Cookie wins always; bearer only on /api/*; bearer on non-/api/* is ignored. - proxy.ts switches to getAuthIdentity. - Domain routes (notes, profile/*) switch to getAuthIdentity. - /api/account/tokens stays session-only so a stolen PAT can't self-rotate to hide a breach. API - GET /api/account/tokens — list active. - POST /api/account/tokens — create; plaintext in response once; hard cap of 25 active per user. - DELETE /api/account/tokens/[id] — revoke (soft). Cross-user 404. Frontend - /account/tokens server-prefetches list, hydrates into TokensClient. - TanStack Query + TanStack Form. Plaintext rendered once in a RevealBanner with copy button; never re-fetched, never cached. - /account page gains a "Manage tokens →" link. Security - .gitleaks.toml: custom rule for carol_pat_* so a leaked PAT trips both pre-commit and CI. Scoped allowlist for deterministic test-fixture lookup-id literals (16-hex) that trip the upstream generic-api-key rule. Tests (322 pass / 55 skipped Postgres legs, local SQLite-only) - DB repo (dual-engine), DTO/zod, PAT generate/verify, identity helper (cookie vs bearer vs both vs neither vs malformed), end-to-end /api/account/tokens including session-only enforcement, plaintext-once, post-revoke 401, and cross-user 404 + A-PAT-sees- no-B-data through /api/notes. Docs - ADR-0020: split-id + argon2id, dual-auth proxy, tokens-panel- session-only, and what to reconsider (cost / self-rotation / enforcement). Scopes day-1: a single `agent` enum value; no route enforces yet — lands with MCP in epic #47.📊 Test coverage
Patch coverage: 94.4% (303/321 added lines) ✅ (soft target ≥ 80%)
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Changed files in this PR (source only — tests excluded):
app/api/account/tokens/[id]/route.tsapp/api/account/tokens/route.tsapp/api/notes/[id]/route.tsapp/api/notes/route.tsapp/api/profile/contacts/[id]/route.tsapp/api/profile/contacts/route.tsapp/api/profile/picture/route.tsapp/api/profile/route.tsdb/migrations/009_personal_access_tokens.tsdb/migrator.tsdb/repositories/personal-access-tokens.tslib/auth/identity.tslib/auth/pat.tslib/dto/pat.tsSoft thresholds per ADR-0019. Coverage is informational and does not block merge.