chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
- .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
This commit is contained in:
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal file
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 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:
|
||||
|
||||
1. **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.
|
||||
2. **Silent logouts.** When the token expired mid-session the browser just
|
||||
started receiving 401s with no graceful recovery path.
|
||||
3. **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 as `Authorization: Bearer …`. `validate_token()` in
|
||||
`encoach_api.controllers.base` rejects tokens whose `type` is anything other
|
||||
than `"access"`.
|
||||
- **Refresh token** — 7 d TTL, carries `type: "refresh"` and a unique `jti`.
|
||||
Every issued refresh token is logged in a new `encoach.jwt.token` Odoo model
|
||||
(the revocation ledger) with fields for `user_id`, `issued_at`, `expires_at`,
|
||||
`last_used_at`, `revoked`, `user_agent`, `remote_ip`.
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /api/login` — returns `access_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`.
|
||||
Reference in New Issue
Block a user