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