docs(07.2): capture phase context
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# Phase 7.2: Security — JTI Claim + Redis Access-Token Revocation - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-06-05
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Phase 7.2 delivers two security primitives: (1) a `jti` UUID claim embedded in every new access token, and (2) a `user_nbf:{user_id}` Redis key checked in `get_current_user` that immediately invalidates all tokens issued before a security event (password change, TOTP enroll/revoke, account deactivation). This closes the 15-minute gap where a revoked session's live access token remains valid even after its refresh tokens are killed.
|
||||||
|
|
||||||
|
No schema migrations. No new endpoints. No frontend changes.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### D-01 — JTI claim
|
||||||
|
Add `jti=str(uuid.uuid4())` to the `create_access_token` payload in `backend/services/auth.py`. The `jti` claim is included on every token but is used only as an identity handle — per-JTI Redis tracking is NOT implemented (user-level NBF replaces it, see D-02).
|
||||||
|
|
||||||
|
### D-02 — Revocation approach: user-level NBF key, not per-JTI
|
||||||
|
Set `user_nbf:{user_id}` = Unix timestamp in Redis (TTL = 15 min, the access token lifetime) when any of these security events fire: `change_password`, `enable_totp`, `disable_totp`, admin account deactivation.
|
||||||
|
|
||||||
|
In `get_current_user`, after a successful decode, read `user_nbf:{user_id}` from Redis. If the key exists and `token["iat"] < nbf_timestamp` → raise HTTP 401. This covers ALL sessions uniformly — the calling session's own token is also immediately blocked (its iat predates now()), but it recovers transparently by exchanging its still-live refresh token (which Phase 7.1 D-02 preserved) for a new access token with iat > nbf.
|
||||||
|
|
||||||
|
Per-JTI tracking is not added: the jti claim is reserved for Phase 7.3+ (ES256 migration) where it may be needed alongside the algorithm upgrade.
|
||||||
|
|
||||||
|
### D-03 — Redis access pattern in get_current_user
|
||||||
|
Use `request.app.state.redis` directly inside `get_current_user`. This is consistent with the existing pattern in `backend/api/auth.py` (lines 197, 566). `get_current_user` already accepts `request: Request`, so no signature change is needed. No new `get_redis` dependency function.
|
||||||
|
|
||||||
|
### D-04 — Redis failure handling: fail-open
|
||||||
|
If the Redis `GET user_nbf:{user_id}` call raises an exception (connection error, timeout), log a warning and allow the request to proceed. Same pattern as the HIBP fail-open in `services/auth.py`. Availability takes priority over blocking tokens during a Redis outage.
|
||||||
|
|
||||||
|
### D-05 — Admin account deactivation coverage
|
||||||
|
The existing deactivation handler in `backend/api/admin.py:351` already calls `revoke_all_refresh_tokens`. Add the `user_nbf` write there too. The target user's access token (unknown JTI) is fully blocked via the user-level key; no per-JTI lookup needed.
|
||||||
|
|
||||||
|
### D-06 — TTL for user_nbf key
|
||||||
|
15 minutes — matches the access token TTL. Keys auto-expire when the last old token would have anyway. No Redis accumulation over time.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Authentication service
|
||||||
|
- `backend/services/auth.py` — `create_access_token` (~line 86): add `jti` claim here; `revoke_all_refresh_tokens` (~line 218): pattern for where to add `user_nbf` writes; TOTP `verify_totp` (~line 271): example of Redis access from a service function
|
||||||
|
- `backend/deps/auth.py` — `get_current_user` (~line 38): the NBF check is added here, after `decode_access_token` succeeds and before the DB lookup
|
||||||
|
|
||||||
|
### Admin deactivation
|
||||||
|
- `backend/api/admin.py:351` — deactivation handler: add `user_nbf` write here alongside `revoke_all_refresh_tokens`
|
||||||
|
|
||||||
|
### Phase 7.1 decisions (must not conflict)
|
||||||
|
- `.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-CONTEXT.md` — D-02: current refresh token is preserved on privilege change; this is why the 401→refresh recovery in D-02 above works
|
||||||
|
|
||||||
|
### Security concern definitions
|
||||||
|
- `.planning/codebase/CONCERNS.md` §"No JTI Claim and No JTI Revocation in Redis" — original concern description and fix approach
|
||||||
|
|
||||||
|
### CLAUDE.md security rules
|
||||||
|
- `CLAUDE.md` §"Login token hardening" — mandates JTI in every token stored in Redis; and "Password change, TOTP enroll/revoke, and account deactivation immediately revoke all active sessions"
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `create_access_token` in `services/auth.py:86` — add `jti=str(uuid.uuid4())` to the payload dict; PyJWT encodes it verbatim
|
||||||
|
- `decode_access_token` in `services/auth.py:102` — returns the full payload dict; jti is accessible as `payload["jti"]` after decode
|
||||||
|
- `app.state.redis` — aioredis client, `await redis.get(key)` returns `bytes | None`; `await redis.set(key, value, ex=seconds)` writes with TTL
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Redis key style: `totp_used:{user_id}:{code}` — use same `snake_case:colon` pattern → `user_nbf:{user_id}`
|
||||||
|
- Fail-open: `backend/services/auth.py` HIBP check returns `False` on network errors and logs a warning — mirror this exact pattern for the Redis check in `get_current_user`
|
||||||
|
- `request.app.state.redis` access: `backend/api/auth.py:197` and `:566` — copy this inline access pattern verbatim
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `backend/services/auth.py` — `create_access_token`: only file generating JWTs; change here propagates to all token issuance
|
||||||
|
- `backend/deps/auth.py` — `get_current_user`: only entry point for validating access tokens on all protected routes; NBF check belongs here
|
||||||
|
- `backend/api/auth.py` — `change_password`, `enable_totp`, `disable_totp`: add `await redis.set(f"user_nbf:{user_id}", int(time.time()), ex=900)` before `session.commit()`
|
||||||
|
- `backend/api/admin.py:351` — deactivation handler: same Redis write pattern
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- The `user_nbf` value stored in Redis should be a Unix integer timestamp (`int(time.time())`). In `get_current_user`, parse it as `int(nbf_bytes.decode())` and compare to `payload["iat"]` (which PyJWT decodes as an int automatically).
|
||||||
|
- TTL = 900 seconds (15 * 60) — hardcode this constant alongside `ACCESS_TOKEN_EXPIRE_MINUTES` in settings or services/auth.py.
|
||||||
|
- The `jti` payload key is lowercase, consistent with RFC 7519. PyJWT accepts arbitrary keys in the payload dict.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- **Per-JTI Redis tracking** — storing each issued JTI in Redis for individual token revocation. Not needed because user-level NBF covers the same use case more efficiently.
|
||||||
|
- **Phase 7.3 — ES256 algorithm upgrade**: Replace HS256 with ECDSA P-256 key pair. The jti claim added in this phase is a prerequisite. Tracked in CONCERNS.md §"JWT Algorithm Downgrade: HS256 Instead of ES256".
|
||||||
|
- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = HMAC of `User-Agent + Accept-Language`. Tracked in CONCERNS.md §"No Token Fingerprint / Token Binding".
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted*
|
||||||
|
*Context gathered: 2026-06-05*
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# Phase 7.2: Security — JTI Claim + Redis Access-Token Revocation - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Phase:** 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
**Areas discussed:** Revocation scope, Redis injection, Redis failure behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revocation scope
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Current session only (per-JTI) | Revoke only the JTI in the current request. Other sessions' access tokens stay valid up to 15 min — refresh tokens already gone (Phase 7.1), so no extension possible. | |
|
||||||
|
| All sessions (user-level NBF key) | Set `user_nbf:{user_id}` = now() in Redis. `get_current_user` checks `token.iat < nbf` — all tokens issued before the event are immediately invalid. One extra Redis read per request. | ✓ |
|
||||||
|
|
||||||
|
**Follow-up — TTL for user_nbf key:**
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| 15 minutes (access token TTL) | Keys auto-expire when the last old token would anyway. Minimal Redis footprint. | ✓ |
|
||||||
|
| Indefinitely (no TTL) | Extra safety margin; accumulates keys permanently. | |
|
||||||
|
|
||||||
|
**Follow-up — calling session's own token is also blocked:**
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Transparent refresh is fine | 401 → refresh (still-live cookie) → new token. Current session recovers silently. Clean uniform implementation. | ✓ |
|
||||||
|
| Keep current session alive | Requires JTI whitelist, adds complexity. | |
|
||||||
|
|
||||||
|
**Notes:** User-level NBF cleanly covers all sessions without per-JTI tracking infrastructure. The 401→refresh recovery is transparent because Phase 7.1 D-02 kept the current refresh token alive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis injection
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| `request.app.state.redis` (inline) | `get_current_user` already has `request: Request`. Consistent with auth.py handler pattern (lines 197, 566). Zero new infrastructure. | ✓ |
|
||||||
|
| `get_redis` FastAPI dependency | More testable in isolation but adds a new dependency function and changes `get_current_user` signature. | |
|
||||||
|
|
||||||
|
**Notes:** Consistency with existing inline pattern was the deciding factor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis failure behavior
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Fail-open: allow the request through | Log a warning, skip the check. Same as HIBP pattern. Availability wins. | ✓ |
|
||||||
|
| Fail-closed: raise 503 | Security wins; any Redis blip takes down authentication. | |
|
||||||
|
|
||||||
|
**Notes:** Matches the HIBP fail-open philosophy already established in the codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Per-JTI Redis tracking (superseded by user-level NBF)
|
||||||
|
- Phase 7.3 — ES256 algorithm upgrade
|
||||||
|
- Phase 7.4 — Token fingerprinting / token binding
|
||||||
Reference in New Issue
Block a user