feat(api): token-based auth — bearer + refresh (#180) #194

Merged
james merged 5 commits from 180-token-auth into main 2026-06-21 02:21:48 +00:00
Owner

Closes #180.

Summary

Layers bearer access + refresh tokens on top of the existing cookie-session auth (ADR-0004) and OAuth2 (ADR-0015), per ADR-0027 §6. The web build keeps the existing same-origin cookie flow unchanged. Native (Android, Linux Flatpak) gets Authorization: Bearer <token> against two new endpoints:

  • POST /api/auth/token — mint a bearer pair from local credentials (grantType: "password"). Reuses attemptLogin so the rate-limit window and "don't leak account existence" rules carry over from /api/auth/login.
  • POST /api/auth/refresh — rotate a refresh token; returns a fresh access+refresh pair. Reuse detection burns the whole token family.

Both credentials resolve to the same User at the session layer (lib/auth/identity.ts), so every domain endpoint that accepts a session also accepts a bearer access token with zero per-route changes.

Decisions

  • DB-backed opaque tokens, not JWTs. Same trade-off ADR-0004 made for sessions — one DB read per request buys immediate revocation. No "ignore until expiry" gap.
  • (lookup_id, hash) split, argon2id. Mirrors PATs (ADR-0021). The lookup half is indexed UNIQUE; the secret half is argon2id-hashed with the same parameters as passwords.
  • Distinct wire prefixes. cat_ (access), crt_ (refresh), carol_pat_ (PAT). The identity reader routes presented bearers to the right verifier by cheap string match before paying the argon2id cost.
  • family_id ties rotations. Every rotation in a chain shares the family_id. Replaying a consumed refresh token revokes the whole family in a single update; the legitimate holder re-authenticates via /api/auth/token.
  • Expired refresh tokens reject without burning the family. That's a timeout, not an attack. Only true replays (a refresh token with revoked_at != null) cascade.
  • Constant-time outcome surfacing on /api/auth/refresh. Invalid, expired, and reused tokens all return the same 401 invalid_refresh_token — no oracle on which side of the rotation race a presented token landed on.
  • TTLs are env-tunable. ACCESS_TOKEN_TTL_SECONDS (default 900) and REFRESH_TOKEN_TTL_SECONDS (default 2592000) — see README §Authentication.

Out of scope

  • Native secure-storage wiring on the client side. That lands with the Expo scaffolding ticket.
  • OAuth-completion → token handoff. The existing OAuth callback issues a session cookie; the callback-to-token bridge for native is a follow-up. Today grantType: "password" is the only supported grant; the discriminated-union request shape leaves room without a breaking change.
  • Personal Access Tokens for MCP / external agents (#47, ADR-0021) — orthogonal surface, already shipped.

Test plan

  • npm run typecheck green
  • npm run lint green
  • npm run test green — 515 passing (16 new in tests/api/auth-token.test.ts)
  • npm run openapi:check exit 0
  • npm run openapi:coverage exit 0 — 54 (path, method) pairs (was 52)
  • npm run build green
  • Reproduced CI's containerised build: podman run --rm node:22.23.0 ... npm run build exit 0

The 16 new tests cover: mint success/failure, refresh rotation, refresh reuse-detection family-burn, expired-refresh rejection without family-burn, bearer-authenticated domain endpoint, per-user isolation across two bearers, expired access token, revoked access token, malformed bearer.

🤖 Generated with Claude Code

Closes #180. ## Summary Layers bearer access + refresh tokens on top of the existing cookie-session auth (ADR-0004) and OAuth2 (ADR-0015), per ADR-0027 §6. The web build keeps the existing same-origin cookie flow unchanged. Native (Android, Linux Flatpak) gets `Authorization: Bearer <token>` against two new endpoints: - `POST /api/auth/token` — mint a bearer pair from local credentials (`grantType: "password"`). Reuses `attemptLogin` so the rate-limit window and "don't leak account existence" rules carry over from `/api/auth/login`. - `POST /api/auth/refresh` — rotate a refresh token; returns a fresh access+refresh pair. Reuse detection burns the whole token family. Both credentials resolve to the same `User` at the session layer (`lib/auth/identity.ts`), so every domain endpoint that accepts a session also accepts a bearer access token with zero per-route changes. ## Decisions - **DB-backed opaque tokens, not JWTs.** Same trade-off ADR-0004 made for sessions — one DB read per request buys immediate revocation. No "ignore until expiry" gap. - **(lookup_id, hash) split, argon2id.** Mirrors PATs (ADR-0021). The lookup half is indexed UNIQUE; the secret half is argon2id-hashed with the same parameters as passwords. - **Distinct wire prefixes.** `cat_` (access), `crt_` (refresh), `carol_pat_` (PAT). The identity reader routes presented bearers to the right verifier by cheap string match before paying the argon2id cost. - **`family_id` ties rotations.** Every rotation in a chain shares the family_id. Replaying a consumed refresh token revokes the whole family in a single update; the legitimate holder re-authenticates via `/api/auth/token`. - **Expired refresh tokens reject without burning the family.** That's a timeout, not an attack. Only true replays (a refresh token with `revoked_at != null`) cascade. - **Constant-time outcome surfacing on `/api/auth/refresh`.** Invalid, expired, and reused tokens all return the same 401 `invalid_refresh_token` — no oracle on which side of the rotation race a presented token landed on. - **TTLs are env-tunable.** `ACCESS_TOKEN_TTL_SECONDS` (default 900) and `REFRESH_TOKEN_TTL_SECONDS` (default 2592000) — see README §Authentication. ## Out of scope - Native secure-storage wiring on the client side. That lands with the Expo scaffolding ticket. - OAuth-completion → token handoff. The existing OAuth callback issues a session cookie; the callback-to-token bridge for native is a follow-up. Today `grantType: "password"` is the only supported grant; the discriminated-union request shape leaves room without a breaking change. - Personal Access Tokens for MCP / external agents (#47, ADR-0021) — orthogonal surface, already shipped. ## Test plan - [x] `npm run typecheck` green - [x] `npm run lint` green - [x] `npm run test` green — 515 passing (16 new in `tests/api/auth-token.test.ts`) - [x] `npm run openapi:check` exit 0 - [x] `npm run openapi:coverage` exit 0 — 54 (path, method) pairs (was 52) - [x] `npm run build` green - [x] Reproduced CI's containerised build: `podman run --rm node:22.23.0 ... npm run build` exit 0 The 16 new tests cover: mint success/failure, refresh rotation, refresh reuse-detection family-burn, expired-refresh rejection without family-burn, bearer-authenticated domain endpoint, per-user isolation across two bearers, expired access token, revoked access token, malformed bearer. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds migration 011 with `access_tokens` and `refresh_tokens` tables
plus per-row entities and repositories. Both follow the (lookup_id,
hash) split from PATs — index-friendly public half, argon2id-hashed
secret half. `refresh_tokens.family_id` ties every rotation in a
chain together so a replayed refresh token can revoke the whole
family in a single update.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds lib/auth/tokens.ts (mintTokenPair / issueTokenPair /
verifyAccessToken / consumeRefreshToken) and the matching DTOs in
lib/dto/auth-token.ts. Wire prefixes 'cat_' and 'crt_' distinguish
access tokens from refresh tokens and from PATs ('carol_pat_'); the
identity reader routes presented bearers to the right verifier by
prefix.

Reuse detection: presenting a refresh token whose row is already
revoked triggers a revoke of every row sharing that family_id —
the legitimate holder is forced to re-authenticate. Expired (but
otherwise valid) refresh tokens reject without burning the family;
that's a timeout, not an attack.

ACCESS_TOKEN_TTL_SECONDS and REFRESH_TOKEN_TTL_SECONDS are env-tunable
with the standard 15-min / 30-day defaults.

Extends lib/auth/identity.ts to accept the new access-token bearer
alongside the session cookie and PATs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new routes for native (Android / Linux Flatpak) clients:

  - POST /api/auth/token   — mint a bearer pair from local credentials
                             (grantType=password). Reuses attemptLogin
                             so the rate-limit and "don't leak account
                             existence" rules apply.
  - POST /api/auth/refresh — rotate a refresh token; returns a fresh
                             access+refresh pair. Replays surface as
                             401 invalid_refresh_token while burning
                             the whole family.

Both responses share the TokenPairDto envelope; expiry is absolute ISO
8601 so the client schedules rotation without a clock-offset estimate.
Neither endpoint takes a session — they're how a session-less native
client gets credentials in the first place. Already covered by the
'/api/auth/' public-prefix allowlist.

Both paths registered in lib/api/openapi-routes.ts; openapi.json
regenerated; coverage gate goes from 52 -> 54 (path, method) pairs.

16 new tests cover: mint success/failure, refresh rotation, refresh
reuse-detection family-burn, expired-refresh rejection without
family-burn, bearer-authenticated domain endpoint, per-user isolation
across two bearers, expired access token, revoked access token,
malformed bearer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs: note bearer auth in CLAUDE.md, add ACCESS/REFRESH_TOKEN_TTL_SECONDS to README (#180)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 16s
PR / OSV-Scanner (pull_request) Successful in 38s
PR / Static analysis (pull_request) Successful in 56s
PR / Package age policy (soft) (pull_request) Successful in 18s
Secrets / gitleaks (pull_request) Successful in 22s
PR / OpenAPI (pull_request) Successful in 1m46s
PR / Typecheck (pull_request) Successful in 1m53s
PR / Trivy (image) (pull_request) Failing after 1m34s
PR / Lint (pull_request) Successful in 1m56s
PR / Coverage (soft) (pull_request) Successful in 1m26s
PR / Test (sqlite) (pull_request) Successful in 2m8s
PR / npm audit (pull_request) Successful in 2m16s
PR / Build (pull_request) Successful in 2m27s
PR / Test (postgres) (pull_request) Failing after 2m33s
665a2e926f
CLAUDE.md "Auth model is strict" bullet now names lib/auth/identity.ts
as the unified reader so future contributors see cookie session, PAT,
and the new bearer access token all flow through the same gate.

README's Authentication table gains ACCESS_TOKEN_TTL_SECONDS and
REFRESH_TOKEN_TTL_SECONDS — the two env vars introduced by this
ticket — with their defaults (15 min / 30 days) and self-hoster
guidance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |

📊 Test coverage

Patch coverage: 90.5% (362/400 added lines) (soft target ≥ 80%)

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 72.9% ≥ 50%
Branches 80.9% ≥ 75%
Functions 88.8% informational

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

File Patch coverage Overall lines Branches
app/api/auth/refresh/route.ts 91.7% (22/24) 91.7% 88.9%
app/api/auth/token/route.ts 87.9% (29/33) 87.9% 81.8%
db/migrations/011_refresh_tokens.ts 92.3% (36/39) 92.3% 100.0%
db/migrator.ts 100.0% (1/1) 69.2% 50.0%
db/repositories/access-tokens.ts 65.9% (29/44) 65.9% 75.0%
db/repositories/refresh-tokens.ts 87.0% (47/54) 87.0% 75.0%
lib/api/openapi-routes.ts 100.0% (42/42) 100.0% 100.0%
lib/auth/identity.ts 100.0% (20/20) 100.0% 95.2%
lib/auth/tokens.ts 93.9% (108/115) 93.9% 71.8%
lib/dto/auth-token.ts 100.0% (28/28) 100.0% 100.0%

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 90.5% (362/400 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 72.9% ✅ | ≥ 50% | | Branches | 80.9% ✅ | ≥ 75% | | Functions | 88.8% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/auth/refresh/route.ts` | 91.7% (22/24) | 91.7% | 88.9% | | `app/api/auth/token/route.ts` | 87.9% (29/33) | 87.9% | 81.8% | | `db/migrations/011_refresh_tokens.ts` | 92.3% (36/39) | 92.3% | 100.0% | | `db/migrator.ts` | 100.0% (1/1) | 69.2% | 50.0% | | `db/repositories/access-tokens.ts` | 65.9% (29/44) | 65.9% | 75.0% | | `db/repositories/refresh-tokens.ts` | 87.0% (47/54) | 87.0% | 75.0% | | `lib/api/openapi-routes.ts` | 100.0% (42/42) | 100.0% | 100.0% | | `lib/auth/identity.ts` | 100.0% (20/20) | 100.0% | 95.2% | | `lib/auth/tokens.ts` | 93.9% (108/115) | 93.9% | 71.8% | | `lib/dto/auth-token.ts` | 100.0% (28/28) | 100.0% | 100.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
fix(test): drop access_tokens + refresh_tokens before users in CI teardown (#180)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 13s
PR / Static analysis (pull_request) Successful in 42s
PR / OSV-Scanner (pull_request) Successful in 16s
PR / Trivy (image) (pull_request) Failing after 31s
PR / Package age policy (soft) (pull_request) Successful in 14s
Secrets / gitleaks (pull_request) Successful in 15s
PR / Lint (pull_request) Successful in 2m11s
PR / OpenAPI (pull_request) Successful in 2m46s
PR / Typecheck (pull_request) Successful in 2m48s
PR / npm audit (pull_request) Successful in 2m41s
PR / Test (sqlite) (pull_request) Successful in 3m13s
PR / Test (postgres) (pull_request) Successful in 3m13s
PR / Coverage (soft) (pull_request) Successful in 2m21s
PR / Build (pull_request) Successful in 3m28s
5d2e64a784
CI's Postgres test job failed on every db test that uses the shared
_engines.ts wipe — twelve files in a row — with:

  2BP01: cannot drop table users because other objects depend on it
  constraint access_tokens_user_id_fkey on table access_tokens depends on table users
  constraint refresh_tokens_user_id_fkey on table refresh_tokens depends on table users

The new auth tables FK to users but weren't in KYSELY_TABLES, so the
parent drop ran before the child drops. SQLite tolerated this; Postgres
correctly refused.

Adds refresh_tokens + access_tokens to the head of the list. Order
matters — children before parents, per the existing comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 2851492f79 into main 2026-06-21 02:21:48 +00:00
james deleted branch 180-token-auth 2026-06-21 02:21:48 +00:00
Sign in to join this conversation.
No description provided.