Files
curo1305andClaude Sonnet 4.6 7ee87c001b docs(07.2): create phase plan — JTI claim + Redis NBF access-token revocation
3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf
writes), VALIDATION.md, RESEARCH.md with resolved open questions.
Checker: 0 blockers, 1 warning resolved (RESEARCH.md open questions marked).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:56:49 +02:00

293 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
plan: 02
type: execute
wave: 1
depends_on:
- 07.2-01
files_modified:
- backend/services/auth.py
- backend/deps/auth.py
autonomous: true
requirements:
- CONCERNS:JTI-CLAIM
- CONCERNS:JTI-REVOKE-REDIS
tags:
- security
- jwt
- redis
- access-control
must_haves:
truths:
- "Every access token issued by create_access_token contains a unique jti UUID claim (D-01)"
- "get_current_user rejects any access token whose iat predates user_nbf:{user_id} in Redis with HTTP 401 (D-02)"
- "get_current_user accepts tokens whose iat post-dates user_nbf (intended recovery path after refresh)"
- "A Redis outage during the NBF check does not block requests — fail-open with warning log (D-04)"
- "The HTTPException raised by the NBF check is NOT swallowed by the fail-open broad-catch (Pitfall 1)"
artifacts:
- path: "backend/services/auth.py"
provides: "create_access_token with jti=str(uuid.uuid4()) in payload"
contains: "jti"
- path: "backend/deps/auth.py"
provides: "user_nbf Redis check in get_current_user with fail-open + HTTPException re-raise guard"
contains: "user_nbf"
key_links:
- from: "backend/services/auth.py create_access_token"
to: "PyJWT encode payload"
via: "payload dict with new 'jti' key"
pattern: "\"jti\":\\s*str\\(uuid\\.uuid4"
- from: "backend/deps/auth.py get_current_user"
to: "request.app.state.redis"
via: "await redis.get(f\"user_nbf:{payload['sub']}\") + int comparison vs payload['iat']"
pattern: "user_nbf:|app\\.state\\.redis"
- from: "Wave 1 implementation"
to: "Wave 0 xfail stubs (test_auth_deps NBF check, test_task2_auth_service JTI)"
via: "stubs flip to XPASS or are promoted by Wave 2/end-of-phase cleanup"
pattern: "XPASS|xfail.*strict=False"
---
<objective>
Add the jti claim to access token issuance and the user_nbf revocation check
to the central auth dependency.
Purpose: Closes the 15-minute window between Phase 7.1's refresh-token
revocation and natural access-token expiry. Implements D-01 (jti claim) and
D-02/D-03/D-04 (user-level NBF check with fail-open) verbatim.
This plan is single-concern (token issuance + validation) and self-contained:
no API handler or admin code is touched. Wave 2 (Plan 03) wires the actual
`user_nbf` writes into the four security-event handlers.
Output: Two surgical edits — one to services/auth.py (add jti), one to
deps/auth.py (add NBF check block). Wave 0's XFAIL stubs for jti-claim and
NBF-check flip to XPASS. Existing 373-test baseline stays green.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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/services/auth.py
@backend/deps/auth.py
@.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-01-PLAN.md
<interfaces>
<!-- Current create_access_token signature (services/auth.py:8699) -->
def create_access_token(user_id: str, role: str) -> str:
- Returns jwt.encode(payload, settings.secret_key, algorithm="HS256")
- Current payload keys: sub, role, typ, iat, exp
- Wave 1 adds: jti=str(uuid.uuid4())
- uuid is ALREADY imported at module top (services/auth.py:25)
- Settings used: settings.access_token_expire_minutes (already in scope)
<!-- Current get_current_user signature (deps/auth.py:3880) -->
async def get_current_user(
request: Request, # already present, no change
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db),
) -> User:
- Step 1: payload = auth_service.decode_access_token(credentials.credentials) — raises ValueError → 401
- Step 2 (NEW Wave 1): user_nbf check via request.app.state.redis
- Step 3 (existing): uuid.UUID(payload["sub"]) → 401 on KeyError/ValueError
- Step 4 (existing): session.get(User, user_uuid); reject if None or not is_active → 401
- Step 5 (existing): request.state.current_user = user; return user
<!-- Existing fail-open template — services/auth.py check_hibp (~line 394) -->
try:
# ... call ...
except Exception as exc:
logger.warning("HIBP check failed (fail-open): %s", exc)
return False
<!-- Existing TTL constant convention — config.py -->
settings.access_token_expire_minutes: int = 15 (confirmed by RESEARCH.md L182)
TTL for user_nbf key = settings.access_token_expire_minutes * 60 = 900
NOTE: this Wave does NOT write user_nbf — only reads. TTL constant is used by Wave 2 writers.
<!-- Redis access pattern — already used in api/auth.py:197 and :566 -->
redis_client = request.app.state.redis
nbf_bytes = await redis_client.get(f"user_nbf:{payload['sub']}") # returns bytes | None
# Compare:
if nbf_bytes is not None and payload["iat"] < int(nbf_bytes.decode()):
raise HTTPException(401, "Session invalidated", headers={"WWW-Authenticate": "Bearer"})
<!-- PyJWT verified behavior (RESEARCH.md L165) -->
After jwt.decode():
- payload["iat"] is type int (Unix timestamp)
- payload["jti"] is verbatim the string we passed in (PyJWT does not validate jti format)
- Direct integer comparison: payload["iat"] < int(nbf_bytes.decode()) works correctly
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add jti claim to create_access_token</name>
<files>backend/services/auth.py</files>
<read_first>
- backend/services/auth.py lines 130 (module docstring + imports — confirm `import uuid` already present at line 25)
- backend/services/auth.py lines 86117 (create_access_token + decode_access_token — current implementations)
- backend/tests/test_task2_auth_service.py (Wave 0 stub test_create_access_token_includes_jti_claim — this is the test that must flip from XFAIL to XPASS or PASS)
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 1" section (lines 143166)
</read_first>
<behavior>
- Test 1 (positive): test_create_access_token_includes_jti_claim — decoded payload contains key "jti"; value is a string parseable as uuid.UUID; promoted from Wave 0 XFAIL to PASS (remove the xfail decorator in this task).
- Test 2 (uniqueness): two consecutive calls to create_access_token produce tokens whose decoded jti values differ — add a NEW small test alongside the promotion edit (one-line guard: `assert t1["jti"] != t2["jti"]`).
- Test 3 (regression guard): existing test_create_access_token_jwt_format and test_decode_access_token_valid still pass — the payload remains a valid HS256 JWT and existing keys (sub, role, typ, iat, exp) are unchanged.
</behavior>
<action>
Edit `create_access_token` (backend/services/auth.py line 8699): add exactly one key to the `payload` dict, placed after the existing `exp` line for grep-ability:
`"jti": str(uuid.uuid4()),`
Do NOT change the function signature. Do NOT change algorithm. Do NOT add a TTL constant here (Wave 2 handles user_nbf TTL).
Promote the Wave 0 stub `test_create_access_token_includes_jti_claim` in `backend/tests/test_task2_auth_service.py`: remove the `@pytest.mark.xfail(...)` decorator and replace any placeholder body with the real assertions:
- decoded = decode_access_token(token)
- `assert "jti" in decoded`
- `assert isinstance(decoded["jti"], str)`
- `uuid.UUID(decoded["jti"]) # raises ValueError if not a valid UUID`
Append one new test alongside it: `def test_create_access_token_jti_is_unique_per_call():` — mint two tokens for the same user, decode both, assert `t1["jti"] != t2["jti"]`. Imports per existing in-function-scope convention in this test file.
Do NOT modify decode_access_token — PyJWT decodes the jti claim automatically as part of the payload dict.
</action>
<verify>
<automated>cd backend &amp;&amp; pytest tests/test_task2_auth_service.py -v</automated>
</verify>
<acceptance_criteria>
- `cd backend &amp;&amp; pytest tests/test_task2_auth_service.py -v` exits 0 with zero failures
- `grep -E "\"jti\":\\s*str\\(uuid\\.uuid4" backend/services/auth.py` returns 1 match
- `grep -v '^\\s*#' backend/services/auth.py | grep -E "\"jti\"" | head -1` shows the new jti line in create_access_token
- test_create_access_token_includes_jti_claim reports PASSED (no longer XFAIL)
- test_create_access_token_jti_is_unique_per_call reports PASSED
- test_create_access_token_jwt_format and test_decode_access_token_valid still PASSED (no regressions)
- Python REPL check: `from services.auth import create_access_token, decode_access_token; import uuid; t = create_access_token('u1','user'); p = decode_access_token(t); uuid.UUID(p['jti'])` returns a UUID object without raising
</acceptance_criteria>
<done>Every access token contains a unique jti UUID claim; Wave 0 stub promoted; zero existing test regressions.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add user_nbf Redis check to get_current_user with fail-open guard</name>
<files>backend/deps/auth.py</files>
<read_first>
- backend/deps/auth.py (entire file — 115 lines; current get_current_user at lines 3880)
- backend/services/auth.py lines 286297 (existing TOTP replay pattern — the reference template for `await redis.get / await redis.set` and `bytes | None` return type)
- backend/services/auth.py near line 394 (check_hibp fail-open template — confirm exact except-clause pattern with logger.warning)
- backend/api/auth.py lines 195200 and 564568 (existing `request.app.state.redis` access pattern — confirm direct attribute access without dependency injection)
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 3" section (lines 186235) AND "Pitfall 1" section (lines 279284) AND "Pitfall 2" section (lines 286290)
- backend/tests/test_auth_deps.py (three Wave 0 stubs created in Plan 01 — must flip from XFAIL to PASS in this task)
</read_first>
<behavior>
- Test 1 (reject path): `test_get_current_user_rejects_token_when_iat_before_user_nbf` — pre-populates `app.state.redis` key `user_nbf:{uid}` = `b"<ts_future>"`, calls /test/me with a token whose iat (encoded via `create_access_token`, hence `int(now())`) is less than `ts_future`, expects HTTP 401 with response JSON `{"detail": "Session invalidated"}` and `WWW-Authenticate: Bearer` header. Promote from XFAIL.
- Test 2 (allow path): `test_get_current_user_allows_token_when_iat_after_user_nbf` — pre-populates `user_nbf:{uid}` = `b"<ts_past>"`, calls /test/me with a token whose iat > ts_past, expects 200. Promote from XFAIL.
- Test 3 (fail-open): `test_get_current_user_failopen_on_redis_error` — mounts a broken Redis (raises RuntimeError on .get()), calls /test/me, expects 200 (no 5xx, no 401). Promote from XFAIL.
- Test 4 (regression guard): all 34+ existing tests in test_auth_deps.py still PASS (token decode, missing-user 401, deactivated-user 401, admin role check).
- Test 5 (no key path): if `user_nbf:{uid}` key is ABSENT from Redis (the common path), get_current_user must NOT raise — request passes through to the existing DB lookup. This is implicitly covered by the existing passing tests once FakeRedis is mounted (Plan 01 Task 1).
</behavior>
<action>
Add a new block to `get_current_user` in backend/deps/auth.py, inserted AFTER the decode_access_token try/except block (currently lines 5057) and BEFORE the `try: user_uuid = uuid.UUID(payload["sub"])` block (currently line 59).
Implementation requirements (concrete; do NOT inline code in this plan — read RESEARCH.md Pattern 3 for the exact shape):
1. Add module-level imports at top of deps/auth.py:
- `import logging`
- `_logger = logging.getLogger(__name__)` (module-level, after the imports)
2. Inside get_current_user, after the existing decode try/except, add a new try/except block:
- Inner work: `redis_client = request.app.state.redis`; `nbf_bytes = await redis_client.get(f"user_nbf:{payload['sub']}")`; if `nbf_bytes is not None and payload["iat"] < int(nbf_bytes.decode())` → raise `HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session invalidated", headers={"WWW-Authenticate": "Bearer"})`.
- First except: `except HTTPException: raise` — MUST be present and MUST come BEFORE the broad except. This is Pitfall 1 from RESEARCH.md and is also documented in the threat model below (T-7.2-02).
- Second except: `except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc)` — mirrors the HIBP fail-open template at services/auth.py:~394.
3. Comparison direction MUST be `payload["iat"] < int(nbf_bytes.decode())` → reject (Pitfall 2). Do NOT use `<=` (a token issued at the exact same second as the event is allowed — edge case, but matches the "issued strictly before" semantic).
4. The `nbf_bytes` value handling: be tolerant of both `bytes` and `str` returns from Redis (FakeRedis in tests may return either depending on what was stored). Use `nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes` before `int(...)`. Comment this with a reference to FakeRedis in test fixtures.
5. Do NOT add a `get_redis` FastAPI dependency. Per D-03 the access is direct via `request.app.state.redis`.
6. Do NOT change the function signature (request: Request is already present).
7. Do NOT touch `get_current_admin` or `get_regular_user` — they wrap get_current_user via Depends and inherit the check transparently.
Promote the three Wave 0 stubs in backend/tests/test_auth_deps.py:
- Remove the `@pytest.mark.xfail(...)` decorators from all three NBF-check tests created in Plan 01.
- Fill in the placeholder bodies with concrete assertions:
* Reject test: set `auth_client._transport.app.state.redis._store[f"user_nbf:{user.id}"] = (b"9999999999", None)` (FakeRedis tuple format: (value, expiry)) or use `await app.state.redis.set(f"user_nbf:{user.id}", b"9999999999")`. Mint token with create_access_token. GET /test/me with Bearer. Assert resp.status_code == 401 and resp.json()["detail"] == "Session invalidated".
* Allow test: set the key to `b"1"` (Unix ts way in the past). Mint token. Assert resp.status_code == 200.
* Fail-open test: replace app.state.redis with a small `class _BrokenRedis` whose async get/set raise RuntimeError. Mint a token. Assert resp.status_code == 200 (NOT 5xx, NOT 401).
Verify by direct grep that the HTTPException re-raise guard is in place — Pitfall 1 is the single highest-impact correctness risk in this plan.
</action>
<verify>
<automated>cd backend &amp;&amp; pytest tests/test_auth_deps.py tests/test_auth_api.py -v</automated>
</verify>
<acceptance_criteria>
- `cd backend &amp;&amp; pytest tests/test_auth_deps.py tests/test_auth_api.py -v` exits 0 with zero failures
- `grep -c "user_nbf:" backend/deps/auth.py` returns &gt;= 1
- `grep -E "except HTTPException:\\s*$" backend/deps/auth.py` returns &gt;= 1 line — the re-raise guard is present
- `grep -E "except HTTPException" backend/deps/auth.py` line number is LESS than the `grep -n "except Exception" backend/deps/auth.py` line number (re-raise guard appears BEFORE broad catch — confirm with two grep -n calls and inspect ordering)
- `grep -E "import logging" backend/deps/auth.py` returns &gt;= 1
- `grep -E "_logger.warning|logger.warning" backend/deps/auth.py` returns &gt;= 1
- `grep -c "Session invalidated" backend/deps/auth.py` returns &gt;= 1
- `grep -c "request.app.state.redis" backend/deps/auth.py` returns &gt;= 1
- test_get_current_user_rejects_token_when_iat_before_user_nbf reports PASSED (no longer XFAIL)
- test_get_current_user_allows_token_when_iat_after_user_nbf reports PASSED
- test_get_current_user_failopen_on_redis_error reports PASSED
- All existing test_auth_deps tests (get_current_user_returns_user, deactivated user 401, missing user 401, admin role check) still PASSED
- All existing test_auth_api tests still PASSED (the NBF-write stubs from Plan 01 remain XFAIL — they are Wave 2's job to flip)
</acceptance_criteria>
<done>get_current_user enforces user_nbf with fail-open + HTTPException re-raise guard; three NBF-check stubs promoted to PASSED; zero regressions in 34+ baseline auth-dep tests.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (Authorization: Bearer) | Untrusted JWT crosses here; signature + typ + NBF all validated in get_current_user |
| API → Redis (app.state.redis) | Trusted local network within Docker Compose; failures must not deny service (D-04 fail-open) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-7.2-01 | Elevation of Privilege | get_current_user (NBF check) | mitigate | After successful decode, read `user_nbf:{payload['sub']}` from Redis; if present and `payload["iat"] < int(nbf_bytes.decode())`, raise HTTP 401 "Session invalidated" with WWW-Authenticate header. Closes the 15-minute window where a revoked session's live access token remains valid. |
| T-7.2-02 | Elevation of Privilege | fail-open except block in get_current_user | mitigate | Explicit `except HTTPException: raise` MUST precede the broad `except Exception` — otherwise the intentional 401 from the NBF check is swallowed and a revoked token leaks through. Verified by source assertion (grep ordering) in Task 2 acceptance criteria. |
| T-7.2-03 | Elevation of Privilege | NBF comparison direction | mitigate | Comparison is `payload["iat"] < int(nbf_bytes.decode())` — token issued BEFORE the event is rejected. Pitfall 2 from RESEARCH.md. Verified by test_get_current_user_rejects_token_when_iat_before_user_nbf. |
| T-7.2-04 | Denial of Service / EoP | Redis outage during NBF check | accept | D-04 mandates fail-open: if Redis raises Exception, log a warning and allow the request to proceed. Mirrors the HIBP fail-open pattern (services/auth.py:~394). Availability is prioritized over blocking-during-outage; the surface area is the 15-minute access-token lifetime. |
| T-7.2-05 | Information Disclosure | jti claim in JWT body | accept | jti is a UUIDv4 (no PII, no user identifier). RFC 7519 standard. Visible in any base64-decoded JWT — acceptable. |
| T-7.2-W1-SC | Tampering | npm/pip/cargo installs | n/a | No packages installed in this plan — uses only stdlib (uuid, logging) already imported in scope (RESEARCH.md "Package Legitimacy Audit" intentionally blank) |
</threat_model>
<verification>
- `cd backend && pytest tests/test_auth_deps.py tests/test_task2_auth_service.py -v` — zero failures; 4 stubs from Plan 01 (jti + 3 NBF-check) now PASSED
- `cd backend && pytest -v` — full suite green; zero new failures vs Phase 7.1's 373-test baseline (the 3 NBF-write stubs from Plan 01 Task 3 + 2 admin stubs from Plan 01 Task 4 remain XFAIL — they are Wave 2's job)
- Source assertions (Task 2 acceptance criteria) prove the Pitfall 1 re-raise guard is in place — this is the single most consequential correctness check in the plan
</verification>
<success_criteria>
- Every access token issued after this plan deploys contains a `jti` UUID claim (verifiable: decode any new token)
- get_current_user rejects tokens issued before any user_nbf timestamp written for that user (verifiable: pytest tests/test_auth_deps.py -k nbf)
- Redis outages produce log warnings but do not block requests (verifiable: test_get_current_user_failopen_on_redis_error)
- HTTPException raised by the NBF check is NEVER caught by the fail-open broad-except (verifiable: source ordering grep + test behavior)
- Wave 2 (Plan 03) can write `user_nbf:{user_id}` from the four security-event handlers and the check will fire on the next request from any pre-event token
</success_criteria>
<output>
Create `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-02-SUMMARY.md` when done.
Required fields in SUMMARY: artifacts (services/auth.py + deps/auth.py), patterns_established (NBF check pattern in get_current_user; HTTPException re-raise guard before broad catch; bytes/str tolerance in Redis return values), patterns_to_avoid (do NOT use `<=` in NBF comparison; do NOT add a get_redis dependency; do NOT touch get_current_admin/get_regular_user), provides (jti claim issuance + NBF rejection enforcement — ready for Wave 2 write sites).
</output>