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.
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_idorpermissionsin 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.rsonce defined a duplicateAccessClaimsstruct withfamily_id/permissions/jti. It was dead code — never declared as a module, never imported — and has been removed; see issue #5.)
- (Historical: an orphaned
- No
token_typediscriminator. 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_tokenscollection) — not Redis (the original ADR listed Redis as an option; it is not used). - The
jticlaim 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 therefresh_tokens_are_unique_even_in_same_secondtest injwt.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:
token_version(global kill-switch). Stored on theUserdocument, incremented on password change and password recovery. The JWT middleware (middleware/auth.rs) rejects any access token whosetoken_versionis stale against the user's current value. A short-livedTokenVersionCacheavoids hitting Mongo on every request.- 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
pbkdf2crate 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_versiongives instant global revocation on password change/recovery without maintaining a blocklist of outstanding tokens.- Refresh rotation +
jtidetects token theft: a rotated-away token being presented again signals reuse.
Negative / costs
token_versionrevocation 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
RemoveDone — the orphaned file has been deleted (issue #5, PR #). The livebackend/src/auth/claims.rs(deadAccessClaims) or wire it up intentionally.Claims/RefreshClaimsstructs inbackend/src/auth/jwt.rsare 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
- 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,multi-person-sharing.md(draft) - Product doc:
encryption.md - Related issues: #3, #4