---
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
plan: 03
type: execute
wave: 2
depends_on:
- 07.2-02
files_modified:
- backend/api/auth.py
- backend/api/admin.py
- backend/tests/test_auth_api.py
- backend/tests/test_admin_api.py
autonomous: true
requirements:
- CONCERNS:JTI-CLAIM
- CONCERNS:JTI-REVOKE-REDIS
tags:
- security
- jwt
- redis
- access-control
must_haves:
truths:
- "change_password writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
- "enable_totp writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
- "disable_totp writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
- "admin deactivation writes user_nbf:{user_id} to Redis only when is_active=False (D-05)"
- "admin activation (is_active=True) does NOT write user_nbf (anti-pattern guard)"
- "TTL for all writes is settings.access_token_expire_minutes * 60 (not hardcoded 900)"
- "import time added at module level to api/auth.py and api/admin.py"
artifacts:
- path: "backend/api/auth.py"
provides: "user_nbf Redis write in change_password, enable_totp, disable_totp"
contains: "user_nbf"
- path: "backend/api/admin.py"
provides: "user_nbf Redis write in deactivation handler"
contains: "user_nbf"
key_links:
- from: "backend/api/auth.py change_password"
to: "request.app.state.redis"
via: "await redis.set(f'user_nbf:{current_user.id}', int(time.time()), ex=settings.access_token_expire_minutes * 60)"
pattern: "user_nbf.*current_user\\.id"
- from: "backend/api/admin.py deactivation handler (line ~351)"
to: "request.app.state.redis"
via: "conditional write only when not body.is_active"
pattern: "user_nbf.*user\\.id"
- from: "Wave 2 implementation"
to: "Wave 0 xfail stubs (test_auth_api NBF-write × 3, test_admin_api NBF-write × 1)"
via: "stubs promoted from XFAIL to PASSED"
pattern: "XPASS|xfail.*strict=False"
---
Wire the user_nbf Redis writes into the four security-event handlers that trigger
access-token revocation.
Purpose: Plan 02 established the check (reader); this plan adds the writers. Until
these four writes are in place, the user_nbf:{user_id} key is never set in
production (it only exists in tests via direct FakeRedis manipulation), so the
15-minute revocation window remains open.
This plan is the final implementation step for Phase 7.2. After it completes:
- Any password change, TOTP enroll, TOTP revoke, or admin deactivation writes
user_nbf:{user_id} = now() to Redis with TTL = access token lifetime.
- get_current_user (Plan 02) reads the key and blocks pre-event tokens.
- All Wave 0 xfail stubs from Plan 01 that weren't promoted in Plan 02 flip to PASSED.
Output: Two files edited (api/auth.py, api/admin.py), four test stubs promoted.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md
@.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md
@backend/api/auth.py
@backend/api/admin.py
@backend/tests/test_auth_api.py
@backend/tests/test_admin_api.py
@backend/config.py
Handler already calls:
1. auth_service.verify_password
2. auth_service.check_hibp
3. auth_service.validate_password_strength
4. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
5. write_audit_log(...)
6. await session.commit() ← insert user_nbf write before this line
The redis_client is NOT yet assigned in change_password — use request.app.state.redis inline.
Handler already has at the top:
redis_client = request.app.state.redis ← reuse this variable
Then calls:
1. auth_service.verify_totp(..., redis_client)
2. user.totp_enabled = True
3. auth_service.generate_backup_codes / store_backup_codes
4. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
5. write_audit_log(...)
6. await session.commit() ← insert user_nbf write before this line
(redis_client already in scope — use it directly)
Handler does NOT yet have a redis_client assignment.
Calls:
1. user.totp_enabled = False; user.totp_secret = None
2. delete(BackupCode) for user
3. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
4. write_audit_log(...)
5. await session.commit() ← insert user_nbf write before this line
Handler at line ~351:
if not body.is_active:
# Revoke all refresh tokens on deactivation
await revoke_all_refresh_tokens(session, user.id)
← INSERT user_nbf write HERE (inside the if not body.is_active block, after revoke_all_refresh_tokens)
← Do NOT write if body.is_active is True (anti-pattern from RESEARCH.md)
admin.py does NOT currently import `time` (grep confirms no `import time` at module level).
auth.py does NOT currently import `time` (confirmed by codebase read).
settings.access_token_expire_minutes = 15 (from backend/config.py)
TTL = settings.access_token_expire_minutes * 60 (= 900 at default config)
Use this derived form — do NOT hardcode 900. Import `settings` is already present in api/auth.py.
In api/admin.py, verify `settings` is importable (it uses `from config import settings` per existing imports).
await request.app.state.redis.set(
f"user_nbf:{current_user.id}",
int(time.time()),
ex=settings.access_token_expire_minutes * 60,
)
In enable_totp where redis_client is already assigned:
await redis_client.set(
f"user_nbf:{current_user.id}",
int(time.time()),
ex=settings.access_token_expire_minutes * 60,
)
In admin deactivation, the user variable is the target (not current_user):
await request.app.state.redis.set(
f"user_nbf:{user.id}",
int(time.time()),
ex=settings.access_token_expire_minutes * 60,
)
Task 1: Add import time + user_nbf writes to api/auth.py (change_password, enable_totp, disable_totp)
backend/api/auth.py
- backend/api/auth.py lines 24–35 (module-level imports — add `import time` here, after existing stdlib imports)
- backend/api/auth.py lines 455–510 (change_password handler — find the `await session.commit()` line, insert write above it)
- backend/api/auth.py lines 552–605 (enable_totp handler — redis_client already in scope; find `await session.commit()`, insert write above it)
- backend/api/auth.py lines 607–650 (disable_totp handler — find `await session.commit()`, insert write above it)
- backend/config.py (confirm access_token_expire_minutes field name — e.g., `access_token_expire_minutes: int = 15`)
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 2" section (lines 167–184) and "Anti-Patterns" (do NOT write on activation)
- change_password: after revoke_all_refresh_tokens + write_audit_log and before session.commit(), Redis key user_nbf:{current_user.id} is set with TTL=access_token_expire_minutes*60.
- enable_totp: same write, using the already-assigned redis_client variable, before session.commit().
- disable_totp: same write via request.app.state.redis, before session.commit().
- No write on any other handler (login, logout, register, refresh, etc.).
- `import time` is present at module level of api/auth.py.
Add `import time` to api/auth.py after the existing `import uuid` line (line 26) — alphabetical placement in stdlib imports.
In change_password (line 455): locate the `await session.commit()` line at end of handler. Insert immediately above it:
`await request.app.state.redis.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
In enable_totp (line 552): locate `await session.commit()` at end of handler. `redis_client` is already assigned near the top of this function (`redis_client = request.app.state.redis`). Insert immediately above session.commit():
`await redis_client.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
In disable_totp (line 607): locate `await session.commit()` at end of handler. Insert immediately above it:
`await request.app.state.redis.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
Do NOT add `settings` import (already present). Do NOT extract a TTL constant — the derived form is readable and stays synchronized with config automatically.
cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"
- `grep -E "^import time" backend/api/auth.py` returns 1 match
- `grep -c "user_nbf" backend/api/auth.py` returns >= 3 (one per handler)
- `grep -c "access_token_expire_minutes \* 60" backend/api/auth.py` returns >= 3
- `cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"` exits 0; at least 3 tests report PASSED (was XFAIL)
- `cd backend && pytest tests/test_auth_api.py -v` exits 0 with zero failures (zero regressions to existing change_password + totp tests)
- The three NBF-write stubs (test_change_password_writes_user_nbf_to_redis, test_enable_totp_writes_user_nbf_to_redis, test_disable_totp_writes_user_nbf_to_redis) are promoted from XFAIL to PASSED in this task — update the test bodies, removing the xfail decorators and replacing placeholder bodies with real assertions (see Task 2 of Plan 02 for the assertion pattern: `await app.state.redis.get(f"user_nbf:{user_id}")` returns a non-None bytes-like value whose `int(decode())` > 0)
import time added; user_nbf written in all three auth security-event handlers; stubs promoted to PASSED.
Task 2: Add import time + user_nbf write to api/admin.py deactivation handler
backend/api/admin.py
- backend/api/admin.py lines 24–50 (module-level imports — add `import time` here)
- backend/api/admin.py lines 326–380 (PATCH /api/admin/users/{id}/status handler — locate the `if not body.is_active:` block at ~line 351; the write goes inside this block, after revoke_all_refresh_tokens)
- backend/config.py (confirm access_token_expire_minutes field name)
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 4" (lines 239–252) and "Anti-Patterns" ("Do not write user_nbf for successful activation")
- backend/tests/test_admin_api.py (two Wave 0 stubs: test_deactivate_user_writes_user_nbf_to_redis + test_activate_user_does_not_write_user_nbf — promote both in this task)
- When PATCH /api/admin/users/{id}/status is called with `is_active=false`: after revoke_all_refresh_tokens, user_nbf:{user.id} is written to Redis with TTL=access_token_expire_minutes*60.
- When called with `is_active=true` (reactivation): user_nbf is NOT written (the negative guard test must pass).
- `import time` is present at module level of api/admin.py.
- `settings` must be in scope for the TTL expression — confirm it is already imported in admin.py.
Add `import time` to api/admin.py after existing stdlib imports — alphabetical placement alongside `import uuid` (line 26).
Locate the deactivation block in the status handler (around line 351):
```
if not body.is_active:
# Revoke all refresh tokens on deactivation
await revoke_all_refresh_tokens(session, user.id)
```
Add one line immediately after `await revoke_all_refresh_tokens(session, user.id)`, still inside the `if not body.is_active:` block:
`await request.app.state.redis.set(f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
Verify `settings` is already imported in admin.py (it should be — grep for `from config import settings`). If not present, add `from config import settings` to the imports section.
Promote the two Wave 0 stubs in backend/tests/test_admin_api.py:
- test_deactivate_user_writes_user_nbf_to_redis: remove xfail decorator; replace placeholder body with real assertions: PATCH is_active=false → response 200; then `await client._transport.app.state.redis.get(f"user_nbf:{user.id}")` returns non-None bytes whose `int(decode())` > 0.
- test_activate_user_does_not_write_user_nbf: remove xfail decorator; replace body: PATCH is_active=true (on a deactivated user); then assert `await app.state.redis.get(f"user_nbf:{user.id}")` is None (the key should not exist for a reactivation event).
cd backend && pytest tests/test_admin_api.py -v
- `grep -E "^import time" backend/api/admin.py` returns 1 match
- `grep -c "user_nbf" backend/api/admin.py` returns >= 1
- `grep -c "access_token_expire_minutes \* 60" backend/api/admin.py` returns >= 1
- The user_nbf write line is inside the `if not body.is_active:` block (not at module scope or outside the conditional) — verify with grep -n and line-number inspection
- `cd backend && pytest tests/test_admin_api.py -v` exits 0 with zero failures
- test_deactivate_user_writes_user_nbf_to_redis reports PASSED (promoted from XFAIL)
- test_activate_user_does_not_write_user_nbf reports PASSED (promoted from XFAIL; Redis key must be None for activation path)
- All existing admin tests (test_deactivate_user, test_reactivate_user, etc.) still PASSED
import time added; user_nbf written in admin deactivation handler (conditional on is_active=False only); both stubs promoted to PASSED; zero admin test regressions.
Task 3: Full regression run — verify zero new failures across entire test suite
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-VALIDATION.md (Validation Sign-Off checklist)
- Full pytest suite passes with zero new failures vs the Phase 7.1 baseline.
- All 9 Phase 7.2 behaviors from VALIDATION.md report green: jti-claim PASSED, test-fakeredis PASSED, nbf-write × 4 PASSED, nbf-check × 2 PASSED, nbf-fail-open PASSED.
- No previously-XFAIL stubs remain (all were promoted in Plans 02 and 03).
Run the full backend test suite. Review output for any unexpected failures. If any test introduced by this plan fails, investigate and fix before marking this task done.
cd backend && pytest -v 2>&1 | tail -20
- `cd backend && pytest -v` exits 0 with zero failures
- Output contains no FAILED lines
- `cd backend && pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py tests/test_task2_auth_service.py -v -k "jti or nbf or user_nbf or failopen"` exits 0 with at least 9 PASSED results covering all 9 VALIDATION.md behaviors
- `grep -rn "user_nbf" backend/api/auth.py backend/api/admin.py backend/deps/auth.py` returns >= 4 lines (3 writers in api/auth.py + 1 writer in api/admin.py + 1 reader in deps/auth.py)
Full suite green; all Phase 7.2 behaviors verified; no regressions.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (security events) | Authenticated user or admin triggers password change / TOTP / deactivation; write must fire unconditionally before commit |
| API → Redis (app.state.redis) | Trusted local network; write is best-effort — if Redis is down, the session.commit() still completes (no rollback) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-7.2-01 | Elevation of Privilege | All four security-event handlers | mitigate | user_nbf:{user_id} written with TTL=access_token_expire_minutes*60 before session.commit() on change_password, enable_totp, disable_totp, admin deactivation. Plan 02's get_current_user check fires on the next request from any pre-event token. |
| T-7.2-ACT | Elevation of Privilege | Admin activation path (is_active=True) | mitigate | Write is strictly inside `if not body.is_active:` block. Reactivation does NOT write user_nbf — a freshly reactivated user's first token is issued after reactivation and will have iat > any prior nbf. Verified by test_activate_user_does_not_write_user_nbf. |
| T-7.2-TTL | Elevation of Privilege | TTL mismatch between write sites | mitigate | All four sites use `settings.access_token_expire_minutes * 60` (not hardcoded 900) — stays synchronized if TTL changes in config. |
| T-7.2-W2-SC | Tampering | npm/pip/cargo installs | n/a | No new packages — only `import time` (stdlib) added. RESEARCH.md "Package Legitimacy Audit" is intentionally blank for this phase. |
- `cd backend && pytest -v` — zero failures; full suite green
- `grep -c "user_nbf" backend/api/auth.py` returns >= 3 (one per security-event handler)
- `grep -c "user_nbf" backend/api/admin.py` returns >= 1 (deactivation only)
- `grep -E "^import time" backend/api/auth.py backend/api/admin.py` returns 2 lines
- All 5 NBF-write/check/fail-open stubs from Plan 01 that were not promoted in Plan 02 now PASSED
- `cd backend && bandit -r backend/ --severity-level high` — zero HIGH severity findings introduced by this plan
- All four security-event handlers write user_nbf:{user_id} to Redis with correct TTL
- Reactivation path does NOT write user_nbf
- TTL expression uses settings.access_token_expire_minutes * 60 in all four write sites
- Complete Phase 7.2 test matrix (9 behaviors from VALIDATION.md) reports PASSED
- Zero regressions: Phase 7.1's 373-test baseline unchanged