--- plan: 07.2-03 phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted status: complete wave: 2 completed: 2026-06-06 key-files: created: [] modified: - backend/api/auth.py - backend/api/admin.py - backend/tests/test_auth_api.py - backend/tests/test_admin_api.py --- # Plan 07.2-03 Summary — Wave 2: user_nbf Write Sites ## What Was Built Wired the `user_nbf` Redis writes into all four security-event handlers that trigger access-token revocation, completing the full Phase 7.2 revocation pipeline. **Task 1 — `backend/api/auth.py`:** - Added `import time` at module level (after `import hashlib`) - Added `await request.app.state.redis.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)` immediately before `await session.commit()` in: - `change_password` handler (uses `request.app.state.redis` directly) - `enable_totp` handler (uses `redis_client` already in scope) - `disable_totp` handler (uses `request.app.state.redis` directly) - Promoted all 3 Wave 0 xfail stubs in `test_auth_api.py` to PASSED assertions **Task 2 — `backend/api/admin.py`:** - Added `import time` at module level (after `import uuid`) - Added `from config import settings` at module level (was previously only locally imported) - Added `await request.app.state.redis.set(f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)` inside the `if not body.is_active:` block in the deactivation handler, after `await revoke_all_refresh_tokens(session, user.id)` — does NOT execute on activation path - Promoted both Wave 0 admin xfail stubs in `test_admin_api.py` to PASSED assertions **Task 3 — Full regression run:** - Full backend suite: 387 passed, 1 failed (pre-existing `test_extract_docx` — `ModuleNotFoundError: No module named 'docx'`, unrelated to Phase 7.2), 7 xfailed (pre-existing from prior phases), 6 skipped ## Patterns Established - **user_nbf write pattern**: `await redis.set(f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)` before `session.commit()` - **Conditional deactivation-only write**: write is strictly inside `if not body.is_active:` — reactivation does NOT write user_nbf (invariant verified by `test_activate_user_does_not_write_user_nbf`) - **TTL derived from settings**: `settings.access_token_expire_minutes * 60` — stays synchronized if TTL changes in config, not hardcoded 900 ## Patterns to Avoid - Do NOT write `user_nbf` on activation/reactivation (is_active=True) — would force legitimate users to re-login immediately - Do NOT hardcode TTL as 900 — use `settings.access_token_expire_minutes * 60` ## What This Enables Full Phase 7.2 revocation pipeline is now complete: 1. Every access token contains a `jti` UUID claim (Plan 02) 2. `get_current_user` reads `user_nbf:{user_id}` from Redis and rejects tokens with `iat < nbf` (Plan 02) 3. All four security events write `user_nbf:{user_id}` to Redis: change_password, enable_totp, disable_totp (this plan), and admin deactivation (this plan) Any token issued before a security event is blocked on the next request — the 15-minute access-token window is closed. ## Self-Check: PASSED - All 9 VALIDATION.md behaviors: PASSED - `grep -c "user_nbf" backend/api/auth.py` → 3 (change_password, enable_totp, disable_totp) - `grep -c "user_nbf" backend/api/admin.py` → 1 (deactivation only) - `grep -c "user_nbf" backend/deps/auth.py` → 5 (reader: multiple log/comparison lines) - `except HTTPException: raise` at line 81 precedes `except Exception` at line 83 in `deps/auth.py` — Pitfall 1 guard confirmed - `grep -E "^import time" backend/api/auth.py backend/api/admin.py` → 2 lines - Zero new test failures introduced by Phase 7.2 (pre-existing `test_extract_docx` failure is a missing `python-docx` module, unrelated)