- .github/workflows/ci.yml: two jobs — frontend (tsc --noEmit, lint, build, Playwright) and backend (Postgres 16 + odoo:19 --test-enable --test-tags encoach_api) — catches regressions before merge. - docs/adr/: start an Architecture Decision Record trail with 0001 canonical directory layout, 0002 JWT refresh flow, 0003 paginated response envelope, 0004 RAG metadata + chunking. - docs/PROJECT_SUMMARY.md §21 Hardening Release: full recap of the AI quality loop, compliance, Paymob, i18n, and CI work shipped in this drop, plus new DB tables, REST routes, frontend routes, verification results, and operator-facing configuration. - README.md refreshed for the v4 split-repo doctrine and the new feature surface. - new_project/DEPRECATED.md: formal retirement notice pointing at backend/ as the canonical tree. Made-with: Cursor
2.9 KiB
2.9 KiB
ADR 0002: JWT access + refresh tokens with revocation ledger
- Status: Accepted
- Date: 2026-04-09
- Deciders: Platform team, Security
Context
Originally /api/login issued a single long-lived JWT (24h+) stored in
localStorage. This gave us three problems:
- No revocation. A leaked token was valid until it expired; there was no server-side way to invalidate it short of rotating the global JWT secret.
- Silent logouts. When the token expired mid-session the browser just started receiving 401s with no graceful recovery path.
- Surface area. Every endpoint accepted the same kind of token, so a token intended for a refresh use-case could be replayed as a full API credential.
Decision
Adopt a two-token flow:
- Access token — 1 h TTL, stateless, carries
type: "access". Sent on every request asAuthorization: Bearer ….validate_token()inencoach_api.controllers.baserejects tokens whosetypeis anything other than"access". - Refresh token — 7 d TTL, carries
type: "refresh"and a uniquejti. Every issued refresh token is logged in a newencoach.jwt.tokenOdoo model (the revocation ledger) with fields foruser_id,issued_at,expires_at,last_used_at,revoked,user_agent,remote_ip.
Endpoints:
POST /api/login— returnsaccess_token,refresh_token,expires_in.POST /api/auth/refresh— validates the refresh token, revokes the old ledger row (rotation), and issues a fresh access + refresh pair.POST /api/logout— revokes the supplied refresh token's ledger row.
The frontend (frontend/src/lib/api-client.ts) handles rotation
transparently: on 401 it calls /api/auth/refresh once (coalesced across
concurrent requests) and retries the original request. If refresh fails, all
tokens are cleared and the user is redirected to /login.
A cron (encoach_api.data.cron) purges expired ledger rows daily.
Consequences
- Positive: revocation works — logout or compromise clears the server-side ledger entry and the refresh token is instantly unusable.
- Positive: short access-token TTL limits the blast radius of a leaked Bearer.
- Positive: the refresh flow is invisible to users; no more mid-session logouts.
- Negative: one extra DB round-trip per refresh. Mitigated by the short-lived
access token and the fact that the ledger is indexed on
jti+user_id. - Follow-up: move ledger cleanup from a time-based cron to an event-based cleanup if the table ever grows past a few hundred thousand rows.
Alternatives considered
- Opaque session tokens with a Redis store. Rejected — adds an operational dependency (Redis) that the rest of the stack does not yet require, and complicates horizontal scaling.
- Single JWT with short TTL + silent re-login. Rejected — requires the
client to store credentials or an SSO cookie, neither of which we want in
localStorage.