normogen/docs/adr/jwt-authentication-decision.md
goose 668ea59030
All checks were successful
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m42s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Successful in 3m57s
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 1m58s
Lint and Build / build (push) Successful in 3m48s
Lint and Build / test (push) Successful in 3m57s
chore: remove dead AccessClaims struct (#5)
backend/src/auth/claims.rs was orphaned dead code: auth/mod.rs never
declared 'mod claims', so the file wasn't compiled into the crate at
all. It defined two structs:
- AccessClaims — referenced nowhere; a latent trap (carried
  family_id/permissions fields that suggested enforcement that doesn't
  exist).
- RefreshClaims — a duplicate of the LIVE RefreshClaims in jwt.rs,
  which is what JwtService actually uses.

Deleted the file. Updated the JWT ADR to mark the open item done and
note the removal historically.

Build/clippy/fmt clean (the file wasn't compiled anyway). Closes #5.
2026-07-19 12:23:31 -03:00

8.4 KiB

ADR: JWT Authentication

Status: Implemented (current code is the source of truth) Date: 2026-02-14 (original decision), 2026-07-18 (reconciled with code)

This ADR was originally written during Phase 1 research and described several things that were never built (bcrypt, Redis, family-role permission claims in the JWT, the specific recovery-phrase flow). It has been rewritten to match the implementation. The original reasoning is preserved in the "History" section at the end. Where code and the old text disagree, the code wins.

Context

Normogen authenticates accounts with email + password and authorizes API requests with stateless JWTs. This sits underneath the zero-knowledge encryption layer (see zero-knowledge-decryption.md and encryption.md): authentication proves who you are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks your data. The auth system never sees plaintext health data.

Decision

Token model

Two JWTs, both HS256-signed with a shared secret from config:

Token Lifetime Claims Purpose
Access configurable (JwtConfig.access_token_expiry_minutes, default 15 min) {sub, exp, iat, user_id, email, token_version} Sent on every API request
Refresh configurable (JwtConfig.refresh_token_expiry_days, default 30 days) {sub, exp, iat, jti, user_id, token_version} Exchange for a new access token

Reference: backend/src/auth/jwt.rs (Claims, RefreshClaims, JwtService).

The lifetimes are config-driven, not hardcoded — the original ADR's "15 min / 30 days" are the defaults, not constants.

Claims (actual, as implemented)

The earlier ADR listed richer claims (family_id, permissions, token_type). These are not in the shipped access token. The real Claims struct is:

pub struct Claims {            // access token
    pub sub: String,           // = user_id (ObjectId hex)
    pub exp: usize,
    pub iat: usize,
    pub user_id: String,
    pub email: String,
    pub token_version: i32,
}

pub struct RefreshClaims {     // refresh token
    pub sub: String,
    pub exp: usize,
    pub iat: usize,
    pub jti: String,           // unique per token (see below)
    pub user_id: String,
    pub token_version: i32,
}

Note what is absent and why:

  • No family_id or permissions in the token. Family/permission enforcement is not implemented at the auth layer (see issue #3 — multi-person sharing is an open design problem). Putting unenforced claims in the token would imply protection that doesn't exist.
    • (Historical: an orphaned backend/src/auth/claims.rs once defined a duplicate AccessClaims struct with family_id/permissions/jti. It was dead code — never declared as a module, never imported — and has been removed; see issue #5.)
  • No token_type discriminator. Access and refresh claims are different structs, so a token can only decode as one or the other — the field is redundant and was dropped.

Refresh tokens: persistence, rotation, reuse detection

  • Refresh tokens are stored in MongoDB (refresh_tokens collection) — not Redis (the original ADR listed Redis as an option; it is not used).
  • The jti claim makes every refresh token unique. Without it, two refresh tokens issued in the same second for the same user would be byte-identical, which breaks rotation and reuse detection. (Covered by the refresh_tokens_are_unique_even_in_same_second test in jwt.rs.)
  • On refresh: validate signature + expiry, confirm the token exists and is not revoked in the repository, mint a new access + refresh pair, revoke the old refresh token (rotation).

Token revocation: versioning + session management

Two mechanisms, both implemented:

  1. token_version (global kill-switch). Stored on the User document, incremented on password change and password recovery. The JWT middleware (middleware/auth.rs) rejects any access token whose token_version is stale against the user's current value. A short-lived TokenVersionCache avoids hitting Mongo on every request.
  2. Session management. Refresh tokens are tracked as sessions (GET /api/sessions, DELETE /api/sessions/:id, DELETE /api/sessions/all) so a user can list and revoke individual logins.

Password handling

  • PBKDF2 (not bcrypt — the original ADR mentioned bcrypt), via the pbkdf2 crate with a per-password random salt. (backend/src/auth/password.rs)
  • Under the ZK model, what the server hashes is the auth secret — a base64'd PBKDF2 derivation of the user's password produced client-side — not the raw password. The raw password never leaves the browser. See encryption.md §2.

Password recovery

Recovery is handled by the zero-knowledge wrapped-DEK mechanism, not by the "client encrypts the recovery phrase with the password" scheme the original ADR described. The client derives a recovery KEK from the recovery phrase, unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new password. See zero-knowledge-encryption.md §"Phase 2: Wrapped-DEK Recovery". The server bumps token_version on recovery, invalidating all prior tokens.

What protects what

Auth + the ciphertext store are defended server-side; plaintext health data is never on the server to protect:

Layer Mechanism
Password / auth secret PBKDF2 + per-password salt
Tokens HS256 signature, short access TTL, refresh rotation + jti reuse detection
Revocation token_version (global) + session list (per-login)
Brute force Account lockout (5 attempts → exponential backoff) + rate limiting
Forensics Audit logging (auth attempts, authz checks)

Reference: backend/src/auth/, backend/src/security/, backend/src/middleware/.

Consequences

Positive

  • Stateless access tokens scale horizontally (no DB lookup per request except the cached version check).
  • token_version gives instant global revocation on password change/recovery without maintaining a blocklist of outstanding tokens.
  • Refresh rotation + jti detects token theft: a rotated-away token being presented again signals reuse.

Negative / costs

  • token_version revocation is coarse — it kills all of a user's sessions at once. Per-session revocation is only available via the session list (refresh-token deletion).
  • HS256 with a shared secret means the secret is sensitive infrastructure; a key rotation requires invalidating all tokens (acceptable for the deployment model, but worth noting vs. asymmetric RS/ES keys).

Open items

  • Remove backend/src/auth/claims.rs (dead AccessClaims) or wire it up intentionally. Done — the orphaned file has been deleted (issue #5, PR #). The live Claims / RefreshClaims structs in backend/src/auth/jwt.rs are the only ones.
  • When issue #3 (multi-person sharing) is decided, revisit whether family / share authorization belongs in the JWT or is enforced per-request against a shares collection.

History (original 2026-02-14 decision, superseded where noted)

The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for access-token blacklists; recovery by client-encrypting the recovery phrase with the password; and family_id/permissions claims with parent/child/elderly role matrices. Of these, only JWT with refresh tokens + rotation was built as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis, recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/ permission claims were never wired into the real Claims. This section is kept as a record of the original reasoning; the sections above are authoritative.

References