- jti UUID claim in create_access_token (D-01) - user_nbf Redis NBF check in get_current_user with fail-open + re-raise guard (D-02, D-03, D-04, T-7.2-02) - 4 Wave 0 xfail stubs promoted to PASS; 29 tests passing in target suites
8.8 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | |||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.2-security-jti-claim-redis-access-token-revocation-inserted | 02 | auth |
|
|
|
|
|
|
Phase 07.2 Plan 02: jti Claim + user_nbf NBF Check Summary
One-liner: jti UUID claim added to all access tokens; user_nbf Redis NBF check with fail-open and HTTPException re-raise guard wired into get_current_user, closing the 15-minute revocation window.
What Was Built
Task 1: Add jti claim to create_access_token (TDD)
services/auth.py — one-line surgical edit to create_access_token:
"jti": str(uuid.uuid4()),
Added as the last key after exp in the payload dict. The uuid module was already imported at module top (line 25) — no new imports. PyJWT encodes the jti key verbatim into the signed token and decodes it back as a plain string after verification.
Tests promoted from xfail:
test_create_access_token_includes_jti_claim— verifies"jti"key present, value is astr, and parseable asuuid.UUIDtest_create_access_token_jti_is_unique_per_call— verifies two consecutive calls produce differentjtivalues (new test)
Both promoted to PASS. All existing tests (test_create_access_token_jwt_format, test_decode_access_token_valid) remain PASSED — no regressions.
Task 2: Add user_nbf Redis check to get_current_user (TDD)
deps/auth.py — module-level additions:
import logging
_logger = logging.getLogger(__name__)
get_current_user — new block inserted after decode_access_token try/except and before the uuid.UUID(payload["sub"]) parse:
try:
redis_client = request.app.state.redis
nbf_bytes = await redis_client.get(f"user_nbf:{payload['sub']}")
if nbf_bytes is not None:
nbf_str = nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes
if payload["iat"] < int(nbf_str):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session invalidated",
headers={"WWW-Authenticate": "Bearer"},
)
except HTTPException:
raise # re-raise the 401 we just constructed (T-7.2-02: Pitfall 1 guard)
except Exception as exc:
_logger.warning("Redis user_nbf check failed (fail-open): %s", exc)
Tests promoted from xfail:
test_get_current_user_rejects_token_when_iat_before_user_nbf— sets futureuser_nbf, verifies HTTP 401 +"Session invalidated"+WWW-Authenticate: Bearertest_get_current_user_allows_token_when_iat_after_user_nbf— sets pastuser_nbf, verifies HTTP 200test_get_current_user_failopen_on_redis_error— replacesapp.state.rediswith_BrokenRedisthat raisesRuntimeError, verifies HTTP 200 (not 5xx, not 401)
All 3 promoted to PASS. All existing tests (7 baseline auth-dep tests) still PASSED. Wave 2 write stubs in test_auth_api.py (3 XFAIL) and admin stubs in test_admin_api.py (2 XFAIL) correctly remain XFAIL — they are Wave 2's job.
Verification
Full suite after this plan: 382 passed, 12 xfailed (1 pre-existing test_extract_docx failure from missing python-docx module in local env — confirmed pre-existing before Wave 0 baseline; unrelated to this plan's changes).
Target file suites: tests/test_auth_deps.py tests/test_task2_auth_service.py — 29 passed, 0 xfailed, 0 failures.
Commits
| Task | Commit | Description |
|---|---|---|
| 1 | c00c1fb |
feat(07.2-02): add jti UUID claim to create_access_token |
| 2 | 30ad9fd |
security(07.2-02): add user_nbf Redis NBF check to get_current_user |
Deviations from Plan
None — plan executed exactly as written.
Known Stubs
None — this plan contains no stubs. All 4 Wave 1 stubs (1 jti + 3 NBF-check) were promoted to fully-passing tests. The 5 remaining XFAIL tests in test_auth_api.py and test_admin_api.py are Wave 2 stubs created in Plan 01 — they are out of scope for this plan.
Patterns Established
- NBF check pattern in get_current_user: read
user_nbf:{payload['sub']}fromrequest.app.state.redis; comparepayload["iat"] < int(nbf_str)to reject; strict<not<= - HTTPException re-raise guard before broad catch:
except HTTPException: raiseMUST precedeexcept Exception— verified by line-number ordering grep. This is T-7.2-02 / Pitfall 1 and the single highest-impact correctness invariant in this plan. - bytes/str tolerance for FakeRedis:
nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytesbeforeint(...)— ensures tests usingFakeRedis.set(..., value.encode())work identically to productionaioredisreturning raw bytes. - Fail-open on Redis errors:
except Exception as exc: _logger.warning(...)— no re-raise; mirrors HIBP fail-open pattern in services/auth.py
Patterns to Avoid
- Do NOT use
<=in the NBF comparison — a token issued at the exact same second as the event is intentionally allowed (strictly-before semantic matches D-02) - Do NOT add a
get_redisFastAPI dependency — access must be direct viarequest.app.state.redisper D-03 - Do NOT touch
get_current_adminorget_regular_user— they inherit the NBF check transparently viaDepends(get_current_user) - Do NOT move the broad
except Exceptionbeforeexcept HTTPException: raise— this would swallow the intentional 401 and let revoked tokens through (T-7.2-02)
Provides (for Wave 2)
Wave 2 (Plan 03) can now write user_nbf:{user_id} = int(time.time()) with ex=900 from the four security-event handlers (change_password, enable_totp, disable_totp, admin deactivation) and the check will fire on the very next request from any pre-event access token. The read side is complete; Wave 2 only needs to add the write sites.
Threat Flags
None — no new network endpoints, auth paths, or schema changes introduced. The changes reduce the attack surface by closing the 15-minute revocation window (T-7.2-01 mitigated).
Self-Check: PASSED
backend/services/auth.pymodified with"jti": str(uuid.uuid4())— confirmed by grepbackend/deps/auth.pymodified with NBF check block — confirmed by grep (user_nbf, logging, Session invalidated, request.app.state.redis)backend/tests/test_task2_auth_service.pymodified — xfail removed, real assertions, new uniqueness testbackend/tests/test_auth_deps.pymodified — 3 xfail stubs promoted to passing assertions- Commit
c00c1fbexists:git log --oneline | grep c00c1fb - Commit
30ad9fdexists:git log --oneline | grep 30ad9fd - 29 tests pass in target suites:
pytest tests/test_auth_deps.py tests/test_task2_auth_service.py - HTTPException re-raise guard ordering verified: except HTTPException at line 81 < except Exception at line 83
- jti pattern grep returns 1 match:
"jti": str(uuid.uuid4()),