# 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`](./zero-knowledge-encryption.md) and [`encryption.md`](../product/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: ```rust 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. - `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with `family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not imported or used anywhere in the source (only in stale compiler scratch files under `target/`). It should be removed or wired up; tracked by issue #4. - **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`](../product/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`](./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). - The dead `AccessClaims` struct is a latent trap — someone could wire it up assuming `family_id`/`permissions` are enforced. Should be deleted. ## Open items - **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up intentionally. Tracked under issue #4. - 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 - Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`, `backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`, `backend/src/models/refresh_token.rs`, `backend/src/models/session.rs` - Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md), [`multi-person-sharing.md`](./multi-person-sharing.md) (draft) - Product doc: [`encryption.md`](../product/encryption.md) - Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3), [#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4)