Files
full_encoach_platform/docs/adr/0002-jwt-refresh-token-flow.md
Yamen Ahmad 93c530eef2
Some checks failed
CI / Frontend — lint + build + e2e (push) Has been cancelled
CI / Backend — Odoo HttpCase (push) Has been cancelled
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
2026-04-19 14:16:47 +04:00

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:

  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.