feat(auth): Personal Access Tokens — agent-runtime authentication (#49) #135

Merged
james merged 1 commit from 49-personal-access-tokens into main 2026-06-19 17:15:18 +00:00
Owner

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

carol_pat_<lookup_id>_<secret>
  • 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 in token_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 with create / findByLookupId / findById / listActiveByUserId / touchLastUsed / revoke (soft-delete).

Auth integration

  • lib/auth/pat.tsgenerateToken / parseToken / verifyToken (full parse → row → expiry → revoke → argon2id verify; touches last_used_at).
  • lib/auth/identity.tsgetAuthIdentity(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.ts switches to getAuthIdentity (the existing public-routes / unauthed-redirect / Cache-Control: private, no-store policy is preserved verbatim).
  • Domain routes (/api/notes/*, /api/profile/*) switch to getAuthIdentity. /api/account/tokens* and /api/auth/* stay on getSession directly.

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 into TokensClient. TanStack Query (["account","tokens"]) + TanStack Form (name + optional datetime expiry). Plaintext rendered exactly once in a RevealBanner with copy button; never re-fetched, never written to the cache. /account page gains a Manage tokens → link.

Security

  • .gitleaks.toml gains a custom rule for carol_pat_* so a leaked PAT trips both pre-commit and the CI scan.
  • Scoped allowlist for the deterministic 16-hex token_lookup_id literals in PAT test fixtures (which would otherwise trip the upstream generic-api-key heuristic).

Acceptance criteria

  • personal_access_tokens table migration applies on startup, both engines. Migration 009. Dual-engine repository tests in tests/db/personal-access-tokens.test.ts (SQLite locally + Postgres in CI via TEST_POSTGRES_URL).
  • Creating a token returns the plaintext exactly once; a second view never shows the plaintext. POST /api/account/tokens response carries plaintext; GET /api/account/tokens returns the summary DTO (tests/dto/pat.test.ts asserts the summary has no tokenHash / tokenLookupId / plaintext keys; tests/api/account-tokens.test.ts asserts the list response has no plaintext).
  • A valid 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)".
  • A PAT for user A cannot read user B's data through any endpoint. tests/api/account-tokens.test.ts → "A's PAT does not see B's data" (asserts /api/notes is empty under A's PAT after B creates a note); cross-user DELETE /api/account/tokens/[id] returns 404.
  • Tests run on both DB engines. All repository tests use describePerEngine. Local run: 398 passed | 91 skipped (skipped = Postgres legs; CI runs both).

Local verification

  • npm run typecheck clean.
  • npm run lint clean.
  • npm test → 398 passed, 91 skipped.
  • npm run build clean; new routes registered: /account/tokens, /api/account/tokens, /api/account/tokens/[id].

Composes with

  • #11 / ADR-0004 — extends the auth surface without changing the session model. ADR-0021 explicitly references ADR-0004's "server can revoke what it owns" property as the reason JWT was again the wrong shape here.
  • #21 / ADR-0018 + #23 (Education) + #69 (Skills) — domain routes already in main switch to getAuthIdentity so PATs reach them. Renumbered around them during rebase (migration 009; ADR-0021).
  • #14 / ADR-0005 — argon2id was already in deps for password storage; no new install-script allowlist entry needed.
  • Epic #47 — scope is recorded (agent only today) but no route enforces it yet. Enforcement lands with MCP.

Out of scope (epic-#47 follow-ups)

  • MCP endpoint at /api/mcp — PAT now exists, the streamable-HTTP server is a separate ticket.
  • Scope enforcement — wire the scopes field into a requireScope("agent") guard once MCP lands.
  • Audit log entries on token issue / use / revoke — separate ticket in #47.
  • Self-rotation via PAT (POST /api/account/tokens accepting Bearer). Stays out until audit logging is in place to detect rotation-from-stolen-token.
  • Programmatic CLI: carol tokens create etc. Not in scope; the web UI is the source of truth today.

Test plan

  • CI green on dual-engine repo tests, security scans, conventional-commits gate, install-script allowlist on the Dockerfile.
  • Manual smoke: register, sign in, create a PAT, paste it into curl -H "Authorization: Bearer carol_pat_..." http://localhost:3000/api/notes — see your notes round-trip. Revoke and rerun — 401.
  • Confirm browser navigation to /account with only Authorization: Bearer … (and no cookie) redirects to /login, not 401 — PAT is API-only.
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 ``` carol_pat_<lookup_id>_<secret> ``` - `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 in `token_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 with `create / findByLookupId / findById / listActiveByUserId / touchLastUsed / revoke` (soft-delete). **Auth integration** - `lib/auth/pat.ts` — `generateToken / parseToken / verifyToken` (full parse → row → expiry → revoke → argon2id verify; touches `last_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.ts` switches to `getAuthIdentity` (the existing public-routes / unauthed-redirect / `Cache-Control: private, no-store` policy is preserved verbatim). - Domain routes (`/api/notes/*`, `/api/profile/*`) switch to `getAuthIdentity`. `/api/account/tokens*` and `/api/auth/*` stay on `getSession` directly. **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 into `TokensClient`. TanStack Query (`["account","tokens"]`) + TanStack Form (name + optional datetime expiry). Plaintext rendered exactly once in a `RevealBanner` with copy button; never re-fetched, never written to the cache. `/account` page gains a *Manage tokens →* link. **Security** - `.gitleaks.toml` gains a custom rule for `carol_pat_*` so a leaked PAT trips both pre-commit and the CI scan. - Scoped allowlist for the deterministic 16-hex `token_lookup_id` literals in PAT test fixtures (which would otherwise trip the upstream `generic-api-key` heuristic). ## Acceptance criteria - [x] **`personal_access_tokens` table migration applies on startup, both engines.** Migration 009. Dual-engine repository tests in `tests/db/personal-access-tokens.test.ts` (SQLite locally + Postgres in CI via `TEST_POSTGRES_URL`). - [x] **Creating a token returns the plaintext exactly once; a second view never shows the plaintext.** `POST /api/account/tokens` response carries `plaintext`; `GET /api/account/tokens` returns the summary DTO (`tests/dto/pat.test.ts` asserts the summary has no `tokenHash` / `tokenLookupId` / `plaintext` keys; `tests/api/account-tokens.test.ts` asserts the list response has no `plaintext`). - [x] **A valid `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)"`. - [x] **A PAT for user A cannot read user B's data through any endpoint.** `tests/api/account-tokens.test.ts → "A's PAT does not see B's data"` (asserts `/api/notes` is empty under A's PAT after B creates a note); cross-user `DELETE /api/account/tokens/[id]` returns 404. - [x] **Tests run on both DB engines.** All repository tests use `describePerEngine`. Local run: **398 passed | 91 skipped** (skipped = Postgres legs; CI runs both). ## Local verification - `npm run typecheck` clean. - `npm run lint` clean. - `npm test` → 398 passed, 91 skipped. - `npm run build` clean; new routes registered: `/account/tokens`, `/api/account/tokens`, `/api/account/tokens/[id]`. ## Composes with - #11 / ADR-0004 — extends the auth surface without changing the session model. ADR-0021 explicitly references ADR-0004's "server can revoke what it owns" property as the reason JWT was again the wrong shape here. - #21 / ADR-0018 + #23 (Education) + #69 (Skills) — domain routes already in main switch to `getAuthIdentity` so PATs reach them. Renumbered around them during rebase (migration 009; ADR-0021). - #14 / ADR-0005 — argon2id was already in deps for password storage; no new install-script allowlist entry needed. - Epic #47 — scope is recorded (`agent` only today) but no route enforces it yet. Enforcement lands with MCP. ## Out of scope (epic-#47 follow-ups) - MCP endpoint at `/api/mcp` — PAT now exists, the streamable-HTTP server is a separate ticket. - Scope enforcement — wire the `scopes` field into a `requireScope("agent")` guard once MCP lands. - Audit log entries on token issue / use / revoke — separate ticket in #47. - Self-rotation via PAT (POST `/api/account/tokens` accepting Bearer). Stays out until audit logging is in place to detect rotation-from-stolen-token. - Programmatic CLI: `carol tokens create` etc. Not in scope; the web UI is the source of truth today. ## Test plan - [x] CI green on dual-engine repo tests, security scans, conventional-commits gate, install-script allowlist on the Dockerfile. - [x] Manual smoke: register, sign in, create a PAT, paste it into `curl -H "Authorization: Bearer carol_pat_..." http://localhost:3000/api/notes` — see your notes round-trip. Revoke and rerun — 401. - [x] Confirm browser navigation to `/account` with only `Authorization: Bearer …` (and no cookie) redirects to `/login`, not 401 — PAT is API-only.
feat(auth): Personal Access Tokens — agent-runtime authentication (#49)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 38s
PR / npm audit (pull_request) Successful in 59s
PR / Static analysis (pull_request) Successful in 59s
Secrets / gitleaks (pull_request) Successful in 30s
PR / Lint (pull_request) Successful in 1m13s
PR / Typecheck (pull_request) Successful in 1m21s
PR / Test (postgres) (pull_request) Successful in 1m27s
PR / Trivy (image) (pull_request) Successful in 1m34s
PR / Coverage (soft) (pull_request) Successful in 1m29s
PR / Test (sqlite) (pull_request) Successful in 1m37s
PR / Build (pull_request) Successful in 1m43s
be3710cc9a
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):

Metric Value Soft target
Lines 86.6% ≥ 50%
Branches 80.9% ≥ 75%
Functions 90.8% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
app/api/account/tokens/[id]/route.ts 86.7% (13/15) 86.7% 66.7%
app/api/account/tokens/route.ts 78.6% (44/56) 78.6% 61.5%
app/api/notes/[id]/route.ts 100.0% (5/5) 96.0% 85.7%
app/api/notes/route.ts 100.0% (6/6) 100.0% 91.7%
app/api/profile/contacts/[id]/route.ts 100.0% (4/4) 68.0% 58.8%
app/api/profile/contacts/route.ts 100.0% (6/6) 64.1% 33.3%
app/api/profile/picture/route.ts 100.0% (14/14) 75.0% 63.2%
app/api/profile/route.ts 100.0% (8/8) 89.2% 62.5%
db/migrations/009_personal_access_tokens.ts 92.0% (23/25) 92.0% 100.0%
db/migrator.ts 100.0% (1/1) 67.6% 50.0%
db/repositories/personal-access-tokens.ts 100.0% (63/63) 100.0% 83.3%
lib/auth/identity.ts 100.0% (21/21) 100.0% 93.3%
lib/auth/pat.ts 96.2% (50/52) 96.2% 85.7%
lib/dto/pat.ts 100.0% (45/45) 100.0% 80.0%

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 94.4% (303/321 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 86.6% ✅ | ≥ 50% | | Branches | 80.9% ✅ | ≥ 75% | | Functions | 90.8% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/account/tokens/[id]/route.ts` | 86.7% (13/15) | 86.7% | 66.7% | | `app/api/account/tokens/route.ts` | 78.6% (44/56) | 78.6% | 61.5% | | `app/api/notes/[id]/route.ts` | 100.0% (5/5) | 96.0% | 85.7% | | `app/api/notes/route.ts` | 100.0% (6/6) | 100.0% | 91.7% | | `app/api/profile/contacts/[id]/route.ts` | 100.0% (4/4) | 68.0% | 58.8% | | `app/api/profile/contacts/route.ts` | 100.0% (6/6) | 64.1% | 33.3% | | `app/api/profile/picture/route.ts` | 100.0% (14/14) | 75.0% | 63.2% | | `app/api/profile/route.ts` | 100.0% (8/8) | 89.2% | 62.5% | | `db/migrations/009_personal_access_tokens.ts` | 92.0% (23/25) | 92.0% | 100.0% | | `db/migrator.ts` | 100.0% (1/1) | 67.6% | 50.0% | | `db/repositories/personal-access-tokens.ts` | 100.0% (63/63) | 100.0% | 83.3% | | `lib/auth/identity.ts` | 100.0% (21/21) | 100.0% | 93.3% | | `lib/auth/pat.ts` | 96.2% (50/52) | 96.2% | 85.7% | | `lib/dto/pat.ts` | 100.0% (45/45) | 100.0% | 80.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit f323e17135 into main 2026-06-19 17:15:18 +00:00
james deleted branch 49-personal-access-tokens 2026-06-19 17:15:18 +00:00
Sign in to join this conversation.
No description provided.