normogen/docs/adr/jwt-authentication-decision.md
goose e322145ffb
Some checks failed
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 0s
docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs
Rewrite the encryption/persona/JWT docs to match the implemented code and
record the multi-person sharing design.

- multi-person-sharing.md (NEW): ADR for per-profile DEK + X25519 envelope
  sharing. All five original open questions resolved 2026-07-18. Covers
  divorced-parents, pets, graduation, and elderly-parent care; adds an
  admin permission tier for owner-incapacity. Decided, not yet implemented.
  (#3)
- encryption.md: full rewrite to the implemented wrapped-DEK / Web Crypto
  model; the old version described ZK encryption as 'planned'. (#4)
- jwt-authentication-decision.md: rewritten to match implementation
  (PBKDF2 not bcrypt, no Redis, token_version revocation, real Claims).
  Original aspirational content preserved in a History section. (#4)
- PERSONA_AND_FAMILY_MANAGEMENT.md: 'Current reality' section added, old
  'Implementation Status' marked superseded, encryption section corrected. (#4)
- ENCRYPTION_UPDATE_SUMMARY.md: archived (changelog for a rewrite that
  itself went stale). (#4)
- ADR README index updated for the new + reconciled entries.
2026-07-18 19:26:43 -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.
    • 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 §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).
  • 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