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