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
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.
185 lines
8.4 KiB
Markdown
185 lines
8.4 KiB
Markdown
# 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.
|
|
- (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`](../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).
|
|
|
|
## 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 #<to fill after open>). 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
|
|
|
|
- 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)
|