--- phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted verified: 2026-06-06T00:00:00Z status: passed score: 9/9 must-haves verified overrides_applied: 0 gaps: - truth: "password_reset_confirm writes user_nbf to Redis — pre-reset access tokens are blocked within TTL" status: resolved resolved_at: "2026-06-06" resolution: "password_reset_confirm (api/auth.py:701-753) now has request: Request parameter (line 702) and writes user_nbf:{user.id} to Redis at lines 744-750 before session.commit(). Test test_password_reset_confirm_writes_user_nbf_to_redis in tests/test_auth_api.py is green. VALIDATION.md confirms 84/84 tests passing." deferred: [] human_verification: [] --- # Phase 07.2: JTI Claim + Redis NBF Revocation — Verification Report **Phase Goal:** JTI claim added to access tokens + Redis-based user_nbf revocation check closes the 15-minute access-token window after refresh-token revocation events **Verified:** 2026-06-06 **Status:** passed (9/9) — initial gap (CR-02 password_reset_confirm) resolved during UAT/VALIDATION phase **Re-verification:** 2026-06-06 — gap confirmed resolved in code + VALIDATION.md (84/84 tests passing) --- ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | Every access token issued by `create_access_token` contains a unique `jti` UUID claim | VERIFIED | `services/auth.py:98` — `"jti": str(uuid.uuid4())` present in payload dict after `exp`; `import uuid` already at module top (line 25) | | 2 | `get_current_user` rejects any access token whose `iat` predates `user_nbf:{user_id}` in Redis with HTTP 401 | VERIFIED | `deps/auth.py:68-85` — full NBF check block present; reads `user_nbf:{payload['sub']}`; raises `HTTPException(401, "Session invalidated")` when `payload["iat"] < int(nbf_str)` | | 3 | `get_current_user` accepts tokens whose `iat` post-dates user_nbf (intended recovery path) | VERIFIED | Same block at `deps/auth.py:75` — comparison is strict `<` not `<=`; tokens issued after the event pass through | | 4 | A Redis outage during the NBF check does not block requests — fail-open with warning log | VERIFIED | `deps/auth.py:83-84` — broad `except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc)` with no re-raise | | 5 | The HTTPException raised by the NBF check is NOT swallowed by the fail-open broad-catch (Pitfall 1 guard) | VERIFIED | `deps/auth.py:81-82` — `except HTTPException: raise` at line 81 precedes `except Exception` at line 83; ordering confirmed by grep line numbers | | 6 | `change_password`, `enable_totp`, `disable_totp` each write `user_nbf:{user_id}` to Redis before `session.commit()` | VERIFIED | `api/auth.py:508-511` (change_password), `api/auth.py:607-611` (enable_totp via `redis_client`), `api/auth.py:653-657` (disable_totp); all use `settings.access_token_expire_minutes * 60` TTL | | 7 | Admin deactivation writes `user_nbf:{user_id}` only when `is_active=False` | VERIFIED | `api/admin.py:353-361` — write is strictly inside `if not body.is_active:` block; activation path at line 366 has no write | | 8 | All TTLs use `settings.access_token_expire_minutes * 60` (not hardcoded 900) | VERIFIED | All 4 write sites confirmed by grep: `api/auth.py:511,610,656` and `api/admin.py:360` — all use the derived expression | | 9 | `password_reset_confirm` writes `user_nbf` to close pre-reset access-token window | VERIFIED | `api/auth.py:701-753` — `request: Request` parameter added (line 702); `user_nbf:{user.id}` written at lines 744-750 before `session.commit()`; covered by `test_password_reset_confirm_writes_user_nbf_to_redis` (green) | **Score:** 9/9 truths verified --- ### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `backend/services/auth.py` | `create_access_token` with `jti=str(uuid.uuid4())` | VERIFIED | Line 98: `"jti": str(uuid.uuid4()),` | | `backend/deps/auth.py` | `user_nbf` Redis check with fail-open + HTTPException re-raise guard | VERIFIED | Lines 62-85: full NBF check block; `import logging` at line 23; `_logger` at line 34 | | `backend/api/auth.py` | `user_nbf` write in `change_password`, `enable_totp`, `disable_totp` | VERIFIED | Lines 507-512, 606-611, 652-657 | | `backend/api/admin.py` | `user_nbf` write in deactivation handler only | VERIFIED | Lines 356-361; `import time` at line 26; `from config import settings` at line 31 | --- ### Key Link Verification | From | To | Via | Status | Details | |------|----|-----|--------|---------| | `services/auth.py create_access_token` | PyJWT encode payload | `"jti": str(uuid.uuid4())` in payload dict | WIRED | Line 98; uuid imported at module level line 25 | | `deps/auth.py get_current_user` | `request.app.state.redis` | `await redis_client.get(f"user_nbf:{payload['sub']}")` + int comparison | WIRED | Lines 69-75 | | `api/auth.py change_password` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{current_user.id}", ...)` | WIRED | Line 508-512 | | `api/auth.py enable_totp` | `request.app.state.redis` | `await redis_client.set(f"user_nbf:{current_user.id}", ...)` (redis_client in scope) | WIRED | Lines 607-611 | | `api/auth.py disable_totp` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{current_user.id}", ...)` | WIRED | Lines 653-657 | | `api/admin.py deactivation handler` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{user.id}", ...)` inside `if not body.is_active:` | WIRED | Lines 357-361 | | `api/auth.py password_reset_confirm` | `request.app.state.redis` | user_nbf write | WIRED | `request: Request` added (line 702); `await request.app.state.redis.set(f"user_nbf:{user.id}", ...)` at lines 744-750; resolved via UAT/VALIDATION | --- ### Anti-Patterns Found | File | Line | Pattern | Severity | Impact | |------|------|---------|----------|--------| | `backend/api/auth.py` | 700-753 | ~~Missing `user_nbf` write after password reset confirm~~ | ~~BLOCKER~~ RESOLVED | Fixed during UAT phase: `request: Request` added + Redis write at lines 744-750; test green | | `backend/services/auth.py` | 100, 110 | `algorithm="HS256"` (symmetric) instead of `ES256` (asymmetric) | WARNING | CLAUDE.md Security Protocol mandates ES256; this is tracked and addressed in Phase 7.3 | Note on HS256 (CR-01 from code review): Phase 7.3 (`07.3-security-es256-algorithm-upgrade-inserted`) is already planned and has research/plan artifacts present in `.planning/phases/`. The HS256 finding is deferred to Phase 7.3. It is a WARNING here, not a BLOCKER for Phase 7.2's stated goal — but it is noted. --- ### Requirements Coverage | Requirement | Source Plan | Description | Status | Evidence | |-------------|-------------|-------------|--------|----------| | CONCERNS:JTI-CLAIM | 07.2-01, 07.2-02 | Every access token carries a `jti` UUID claim | SATISFIED | `services/auth.py:98` | | CONCERNS:JTI-REVOKE-REDIS | 07.2-01, 07.2-02, 07.2-03 | `user_nbf` Redis check closes 15-min revocation window on security events | SATISFIED | All 5 write sites verified: change_password (511), enable_totp (610), disable_totp (656), admin deactivation (360), password_reset_confirm (744-750) | --- ### Human Verification Required _(No human verification required — all 9 truths verified by automated tests. 84/84 tests passing per VALIDATION.md 2026-06-06.)_ --- ### Gaps Summary No open gaps. The single gap identified in the initial verification (CR-02: `password_reset_confirm` missing `user_nbf` write) was resolved during the UAT/VALIDATION phase: - `request: Request` parameter added to function signature (line 702) - `await request.app.state.redis.set(f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)` written at lines 744-750 before `session.commit()` - Test `test_password_reset_confirm_writes_user_nbf_to_redis` in `tests/test_auth_api.py` is green - VALIDATION.md (2026-06-06): 84/84 tests passing, `nyquist_compliant: true` --- _Verified: 2026-06-06_ _Verifier: Claude (gsd-verifier)_