feat(api): token-based auth — bearer + refresh (#180) #194
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!194
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "180-token-auth"
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 #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"). ReusesattemptLoginso 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
Userat 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
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_idties 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.revoked_at != null) cascade./api/auth/refresh. Invalid, expired, and reused tokens all return the same 401invalid_refresh_token— no oracle on which side of the rotation race a presented token landed on.ACCESS_TOKEN_TTL_SECONDS(default 900) andREFRESH_TOKEN_TTL_SECONDS(default 2592000) — see README §Authentication.Out of scope
grantType: "password"is the only supported grant; the discriminated-union request shape leaves room without a breaking change.Test plan
npm run typecheckgreennpm run lintgreennpm run testgreen — 515 passing (16 new intests/api/auth-token.test.ts)npm run openapi:checkexit 0npm run openapi:coverageexit 0 — 54 (path, method) pairs (was 52)npm run buildgreenpodman run --rm node:22.23.0 ... npm run buildexit 0The 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
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>Trivy (container image)
Threshold:
high· Total findings: 121 · At/above threshold: 16.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):Changed files in this PR (source only — tests excluded):
app/api/auth/refresh/route.tsapp/api/auth/token/route.tsdb/migrations/011_refresh_tokens.tsdb/migrator.tsdb/repositories/access-tokens.tsdb/repositories/refresh-tokens.tslib/api/openapi-routes.tslib/auth/identity.tslib/auth/tokens.tslib/dto/auth-token.tsSoft thresholds per ADR-0019. Coverage is informational and does not block merge.