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

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.
- `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)