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

24 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
07.2-security-jti-claim-redis-access-token-revocation-inserted 01 execute 0
backend/tests/test_auth_deps.py
backend/tests/test_task2_auth_service.py
backend/tests/test_auth_api.py
backend/tests/test_admin_api.py
true
CONCERNS:JTI-CLAIM
CONCERNS:JTI-REVOKE-REDIS
security
jwt
testing
nyquist
truths artifacts key_links
Every Phase 7.2 behavior has a failing test scaffold before any implementation runs
test_auth_deps.py test app mounts app.state.redis = FakeRedis() so Wave 1 NBF check does not crash existing 34+ auth-dep tests
Wave 0 stubs are skipped/xfailed (strict=False); zero existing tests regress
path provides contains
backend/tests/test_auth_deps.py FakeRedis attached to make_test_app(); NBF-check xfail stubs (iat<nbf→401, iat>nbf→pass, fail-open) FakeRedis
path provides contains
backend/tests/test_task2_auth_service.py test_create_access_token_includes_jti xfail stub jti
path provides contains
backend/tests/test_auth_api.py NBF-write xfail stubs for change_password, enable_totp, disable_totp user_nbf
path provides contains
backend/tests/test_admin_api.py NBF-write xfail stub for admin deactivation handler user_nbf
from to via pattern
backend/tests/test_auth_deps.py make_test_app() request.app.state.redis test_app.state.redis = FakeRedis() set before client yield app.state.redis\s*=\s*FakeRedis
from to via pattern
Wave 0 stubs Wave 1/2 implementation pytest.xfail(strict=False) markers promote to xpass when code lands pytest.xfail|xfail.*strict=False
Pre-implementation scaffolding for Phase 7.2 (Nyquist Wave 0).

Purpose: Two things must be in place before any code in services/auth.py, deps/auth.py, api/auth.py, or api/admin.py is touched:

  1. A FakeRedis instance attached to app.state.redis on the minimal test app in backend/tests/test_auth_deps.py. Without this, Wave 1's NBF check in get_current_user will raise AttributeError: 'State' object has no attribute 'redis' on every existing auth-dep test (Pitfall 4 from RESEARCH.md).

  2. Failing/xfailed test stubs that pin every Phase 7.2 behavior listed in 07.2-VALIDATION.md (JTI presence, NBF write on each security event, NBF check accept/reject, fail-open). Wave 1 and Wave 2 implementation then "flips" these from xfail to passing — verifiable test signal.

Output: 4 modified test files. Test suite still green (zero new failures); new stubs report as XFAIL with strict=False.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 @.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-VALIDATION.md @backend/tests/test_auth_deps.py @backend/tests/test_auth_api.py @backend/tests/test_task2_auth_service.py @backend/tests/test_admin_api.py

Existing FakeRedis class — backend/tests/test_auth_api.py lines 4786:

  • class FakeRedis with async get/set/incr/expire/close
  • Already used by authed_client fixture (line 102): fake_redis = FakeRedis(); app.state.redis = fake_redis
  • Supports await redis.get(key) returning the stored value (or None), and await redis.set(key, value, ex=seconds)
  • For Phase 7.2 NBF check, the .get() return type must be bytes-like (RESEARCH.md L210: int(nbf_bytes.decode())). The existing FakeRedis stores arbitrary value types; tests setting user_nbf must store bytes (e.g., b"1700000000") for the .decode() call to work.

Existing make_test_app — backend/tests/test_auth_deps.py lines 2237:

  • Builds a minimal FastAPI with /test/me and /test/admin endpoints
  • Does NOT currently set app.state.redis
  • Used by auth_client fixture (line 4051)

Existing test_task2_auth_service.py — backend/tests/test_task2_auth_service.py:

  • test_create_access_token_jwt_format (line 28): existing positive test for create_access_token
  • test_decode_access_token_valid (line 35): decodes and asserts payload contents — TEMPLATE for jti assertion

Existing test_auth_api.py change-password test pattern:

  • Uses authed_client fixture (line 89) which sets app.state.redis = FakeRedis()
  • change_password handler at backend/api/auth.py:455 — already uses request.cookies.get("refresh_token") and writes audit log

Existing test_admin_api.py deactivation tests:

  • test_deactivate_user at line 191 — PATCH /api/admin/users/{id}/status with is_active=False
  • admin_client fixture (line 72) does NOT currently set app.state.redis — Wave 2 (Plan 03) write site uses request.app.state.redis, so admin tests need FakeRedis too (Task 4 below)

Phase 7.2 behaviors to stub (from 07.2-VALIDATION.md Per-Task Verification Map):

  • jti-claim: create_access_token payload contains "jti" key with UUID-format string value
  • test-fakeredis: get_current_user does not raise AttributeError when app.state.redis is FakeRedis
  • nbf-write-change-password: POST /api/auth/change-password sets user_nbf:{user_id} in Redis
  • nbf-write-enable-totp: POST /api/auth/totp/enable sets user_nbf:{user_id} in Redis
  • nbf-write-disable-totp: DELETE /api/auth/totp sets user_nbf:{user_id} in Redis
  • nbf-write-deactivation: PATCH /api/admin/users/{id}/status (is_active=False) sets user_nbf:{user_id} in Redis
  • nbf-check-reject: token with iat < nbf returns 401 "Session invalidated"
  • nbf-check-allow: token with iat > nbf returns 200
  • nbf-fail-open: redis.get raises Exception → request still succeeds (200)

Wave 0 stub convention (from STATE.md key decisions: "Wave 0 stubs: single-line body only"):

  • Body is ONLY pytest.xfail("not implemented yet — Phase 7.2 Wave 1/2", strict=False) or test marker @pytest.mark.xfail(strict=False, reason="...") with placeholder assertion
  • No real assertion logic in Wave 0 — that comes in Wave 1/2 when the same stubs are promoted by replacing the xfail with real assertions
Task 1: Mount FakeRedis on test_auth_deps test app and add NBF-check stubs backend/tests/test_auth_deps.py - backend/tests/test_auth_deps.py (current make_test_app + auth_client fixture — lines 2251) - backend/tests/test_auth_api.py lines 4786 (FakeRedis class definition — import this, do not redefine) - backend/tests/test_auth_api.py lines 89123 (authed_client fixture — pattern for app.state.redis assignment) - .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pitfall 4" section (lines 296301) - Test 1 (positive guard): existing test_get_current_user_returns_user still passes after FakeRedis is attached (no regression). - Test 2 (xfail): test_get_current_user_rejects_token_when_iat_before_user_nbf — pre-populates fake_redis["user_nbf:{uid}"] = b"", calls /test/me with a token whose iat < future_ts, expects 401 with detail containing "Session invalidated". - Test 3 (xfail): test_get_current_user_allows_token_when_iat_after_user_nbf — pre-populates fake_redis["user_nbf:{uid}"] = b"", calls /test/me with a token whose iat > past_ts, expects 200. - Test 4 (xfail): test_get_current_user_failopen_on_redis_error — sets app.state.redis to an object whose get() raises Exception, calls /test/me, expects 200 (fail-open per D-04). - All three new tests marked `@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — NBF check not yet implemented")`. Import FakeRedis from tests.test_auth_api at top of file (use relative-style import: `from tests.test_auth_api import FakeRedis` — verify by reading existing `from ...` statements in other Phase tests for the correct module path; conftest uses `from main import app` so absolute `from tests.test_auth_api` is the project convention).
Modify `make_test_app()` (line 22): before returning test_app, set `test_app.state.redis = FakeRedis()`. Keep the existing route definitions unchanged.

Modify `auth_client` fixture (line 40): no signature change; ensure each invocation creates a fresh FakeRedis (because make_test_app() instantiates it). Add a fixture-scope comment noting that Phase 7.2 NBF check reads `request.app.state.redis`.

Append three new test functions after the existing last test:
  - `test_get_current_user_rejects_token_when_iat_before_user_nbf(auth_client, db_session)`
  - `test_get_current_user_allows_token_when_iat_after_user_nbf(auth_client, db_session)`
  - `test_get_current_user_failopen_on_redis_error(auth_client, db_session)`

Each new test:
  - decorated with `@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — NBF check not yet implemented in get_current_user")`
  - decorated with `@pytest.mark.asyncio`
  - Uses _create_user helper (already present, line 54) to insert a user
  - Uses services.auth.create_access_token to mint the token
  - Body: 15 lines of placeholder assertions (e.g., `assert False, "stub"` after the prep), so when Wave 1 implementation lands and the assertion logic is filled in, the xfail flips to xpass. Keep Wave 0 body minimal per STATE.md convention.

For Task 4 (failopen), define a small inline `class _BrokenRedis` whose `async def get(self, key)` raises `RuntimeError("simulated redis down")`. Set `auth_client._transport.app.state.redis = _BrokenRedis()` inside the test (or override via a fixture-local app instance) — this is acceptable in Wave 0 because Wave 1 implementation will read the assignment back out.

Do NOT modify any existing test in the file. Do NOT add real assertion logic for the NBF check beyond placeholder stubs.
cd backend && pytest tests/test_auth_deps.py -v - `cd backend && pytest tests/test_auth_deps.py -v` exits 0 - `grep -c "FakeRedis" backend/tests/test_auth_deps.py` returns >= 2 (import + assignment) - `grep -c "test_app.state.redis" backend/tests/test_auth_deps.py` returns >= 1 - `grep -c "test_get_current_user_rejects_token_when_iat_before_user_nbf\|test_get_current_user_allows_token_when_iat_after_user_nbf\|test_get_current_user_failopen_on_redis_error" backend/tests/test_auth_deps.py` returns 3 - `grep -E -c "pytest\\.mark\\.xfail|pytest\\.xfail" backend/tests/test_auth_deps.py` returns >= 3 - Test output lists at least 3 XFAIL results (or XPASS for any whose body already trivially passes — both acceptable in strict=False) - All previously-existing tests in test_auth_deps.py still PASS (no regressions in the 34+ baseline) FakeRedis mounted on test app; three NBF-check stubs added with xfail(strict=False); zero existing test regressions. Task 2: Add JTI presence stub in test_task2_auth_service.py backend/tests/test_task2_auth_service.py - backend/tests/test_task2_auth_service.py lines 2858 (existing create/decode_access_token tests — TEMPLATE) - backend/services/auth.py lines 86117 (current create_access_token + decode_access_token implementations — note absence of jti) - Test (xfail): test_create_access_token_includes_jti_claim — calls `create_access_token("test-uid", "user")`, decodes via `decode_access_token`, asserts the returned payload dict has key `"jti"` whose value is a non-empty string parseable as a UUID (uuid.UUID(payload["jti"]) does not raise). Append a single new test function after the existing last create/decode_access_token test in the file:
  `def test_create_access_token_includes_jti_claim():` decorated with `@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — jti claim not yet added to create_access_token")`.

Body: import create_access_token and decode_access_token from services.auth (follow the import style of the surrounding tests — they use function-local imports per test). Mint a token. Decode it. Assert `"jti" in payload` and `uuid.UUID(payload["jti"])` succeeds. Import `uuid` at function scope (matches surrounding style) if not already module-level imported.

Do NOT modify create_access_token in services/auth.py — that is Wave 1 (Plan 02). Do NOT modify any existing test.
cd backend && pytest tests/test_task2_auth_service.py -v -k jti - `cd backend && pytest tests/test_task2_auth_service.py -v -k jti` exits 0 - `grep -c "test_create_access_token_includes_jti_claim" backend/tests/test_task2_auth_service.py` returns 1 - `grep -E -c "xfail.*strict=False" backend/tests/test_task2_auth_service.py` returns >= 1 (new stub) - The new test reports as XFAIL (or XPASS if the placeholder body trivially passes; both acceptable in strict=False) - All existing tests in test_task2_auth_service.py still PASS (zero regressions) JTI presence test stub added with xfail(strict=False); existing tests unaffected. Task 3: Add NBF-write stubs for change_password / enable_totp / disable_totp in test_auth_api.py backend/tests/test_auth_api.py - backend/tests/test_auth_api.py lines 45123 (FakeRedis class + authed_client fixture — already mounts app.state.redis) - backend/api/auth.py lines 452509 (change_password handler — Wave 2 will add the user_nbf write here) - backend/api/auth.py lines 548601 (enable_totp handler — Wave 2 write site) - backend/api/auth.py lines 606641 (disable_totp handler — Wave 2 write site) - Existing test that exercises change_password in test_auth_api.py (grep for `change-password` and copy its login+TOTP setup pattern) - Test (xfail) 1: test_change_password_writes_user_nbf_to_redis — completes register + login (using existing helpers), then POST /api/auth/change-password with valid current+new passwords. After response, asserts `await app.state.redis.get(f"user_nbf:{user_id}")` returns a non-None bytes value parseable as `int(...)`. - Test (xfail) 2: test_enable_totp_writes_user_nbf_to_redis — registers, logs in, calls /totp/setup, supplies a generated TOTP code via /totp/enable, asserts `user_nbf:{user_id}` is set in Redis. - Test (xfail) 3: test_disable_totp_writes_user_nbf_to_redis — same setup as Test 2 plus enable, then DELETE /api/auth/totp, asserts `user_nbf:{user_id}` is set in Redis. Append three new async test functions (after the existing change_password / TOTP tests) named exactly as listed in behavior above. All three: - decorated `@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to handler")` - decorated `@pytest.mark.asyncio` - Use the `authed_client` fixture (already mounts FakeRedis at app.state.redis) - Reuse the existing `_register` / `_login` helpers (lines 3143) and the in-file TOTP test helpers (grep for existing TOTP-enable test pattern in the file; copy fixture/imports) - Body keeps Wave 0 minimal: prep + a single `assert False, "stub — Wave 2 will fill in"` or call `pytest.xfail("Phase 7.2 Wave 2 stub")` inside the body. The xfail strict=False allows both.
Implementation hint (kept in test comments for Wave 2): after the API call returns success, fetch the user_id from the login response payload, then `nbf_bytes = await authed_client._transport.app.state.redis.get(f"user_nbf:{user_id}")` and assert `nbf_bytes is not None` plus `int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0`.

Do NOT modify any handler in backend/api/auth.py — that is Wave 2 (Plan 03). Do NOT modify the FakeRedis class.
cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf" - `cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"` exits 0 - `grep -c "test_change_password_writes_user_nbf_to_redis\|test_enable_totp_writes_user_nbf_to_redis\|test_disable_totp_writes_user_nbf_to_redis" backend/tests/test_auth_api.py` returns 3 - `grep -E -c "user_nbf" backend/tests/test_auth_api.py` returns >= 3 (one per new test) - `grep -E -c "xfail.*strict=False" backend/tests/test_auth_api.py` returns >= 3 (new stubs) - All three new tests report as XFAIL (or XPASS — both acceptable in strict=False) - All existing tests in test_auth_api.py still PASS (zero regressions to register/login/totp/change-password baseline) Three NBF-write stubs added covering change_password, enable_totp, disable_totp; existing auth API tests unaffected. Task 4: Add NBF-write stub + FakeRedis for admin deactivation in test_admin_api.py backend/tests/test_admin_api.py - backend/tests/test_admin_api.py lines 7184 (admin_client fixture — does NOT currently set app.state.redis) - backend/tests/test_admin_api.py lines 191220 (test_deactivate_user — TEMPLATE for the new test) - backend/tests/test_auth_api.py lines 4786 (FakeRedis class — import target) - backend/api/admin.py lines 340380 (deactivation handler — Wave 2 write site) - admin_client fixture mounts `app.state.redis = FakeRedis()` so the deactivation handler (post-Wave 2) can call `request.app.state.redis.set(...)` without AttributeError. - Test (xfail): test_deactivate_user_writes_user_nbf_to_redis — creates a regular user via make_regular_user, sends PATCH /api/admin/users/{id}/status with `{"is_active": false}`, asserts `await app.state.redis.get(f"user_nbf:{user_id}")` returns a non-None bytes value. - Test (negative guard, can be passing immediately): test_activate_user_does_NOT_write_user_nbf — sends PATCH with `{"is_active": true}` on an already-deactivated user, asserts Redis key is NOT set. Marked `@pytest.mark.xfail(strict=False)` because the handler does not yet write the key in either branch — Wave 2 must preserve this invariant. (Mirrors RESEARCH.md Anti-pattern: "Do not write user_nbf for successful activation".) Import FakeRedis at top of file: `from tests.test_auth_api import FakeRedis` (matches project convention for cross-test imports).
Modify `admin_client` fixture (line 72): before `async with AsyncClient(...)`, add `app.state.redis = FakeRedis()`. In the teardown after fixture yield (after `app.dependency_overrides.clear()`), add `app.state.redis = None` (mirrors authed_client teardown at test_auth_api.py:123).

Append two new test functions at end of file (after the last existing test):
  - `test_deactivate_user_writes_user_nbf_to_redis(admin_client)` — async, marked `@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to admin deactivation handler")`. Body: unpack `client, _admin, session = admin_client`; create a regular user via `make_regular_user(session)`; PATCH /api/admin/users/{user.id}/status with `{"is_active": false}`; assert response 200; access `client._transport.app.state.redis` (the FakeRedis instance) and `await` its `.get(f"user_nbf:{user.id}")`; assert the value is not None. Placeholder body acceptable in Wave 0 (single `assert False, "stub"` line is fine).
  - `test_activate_user_does_not_write_user_nbf(admin_client)` — same xfail decorator; deactivate first, then PATCH with `{"is_active": true}`, assert `await app.state.redis.get(f"user_nbf:{user.id}")` is None at the end. Placeholder body acceptable.

Verify no existing admin test regresses: pre-existing admin tests do not call `request.app.state.redis` so the mount is additive and safe.

Do NOT modify any handler in backend/api/admin.py — that is Wave 2 (Plan 03).
cd backend && pytest tests/test_admin_api.py -v - `cd backend && pytest tests/test_admin_api.py -v` exits 0 - `grep -c "from tests.test_auth_api import FakeRedis\|from tests\\.test_auth_api import FakeRedis" backend/tests/test_admin_api.py` returns >= 1 - `grep -c "app.state.redis = FakeRedis" backend/tests/test_admin_api.py` returns >= 1 - `grep -c "test_deactivate_user_writes_user_nbf_to_redis\|test_activate_user_does_not_write_user_nbf" backend/tests/test_admin_api.py` returns 2 - `grep -E -c "xfail.*strict=False" backend/tests/test_admin_api.py` returns >= 2 - All previously-existing admin tests (including test_deactivate_user, test_reactivate_user) still PASS - Full pytest -v reports zero NEW failures vs baseline (XFAILs and XPASSes acceptable) FakeRedis attached to admin_client fixture; deactivation NBF-write stubs (positive + negative-guard) added; existing admin tests unaffected.

<threat_model>

Trust Boundaries

Boundary Description
test → app.state Test fixtures inject fake infrastructure (FakeRedis) onto the live FastAPI app; tests must not leak shared state between test runs

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-7.2-W0-01 Tampering test_admin_api.py admin_client fixture mitigate Reset app.state.redis = None in teardown after yield (mirrors authed_client convention at test_auth_api.py:123) — prevents stale FakeRedis from one test file bleeding into another
T-7.2-W0-02 Repudiation xfail stubs with strict=False accept Stubs are deliberately permissive in Wave 0 — they document expected behavior and act as failing gates when Wave 1/2 lands. The strict=False convention is established across this project (STATE.md).
T-7.2-W0-SC Tampering npm/pip/cargo installs n/a No packages installed in this plan — Phase 7.2 adds zero dependencies (RESEARCH.md "Package Legitimacy Audit" section is intentionally empty)
</threat_model>
- `cd backend && pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_task2_auth_service.py tests/test_admin_api.py -v` — zero new failures vs current baseline (373 passed in Phase 7.1) - `grep -c "FakeRedis" backend/tests/test_auth_deps.py` returns >= 2 (import + assignment) - `grep -c "FakeRedis" backend/tests/test_admin_api.py` returns >= 2 (import + assignment) - All four new stub test sets report as XFAIL (or XPASS — both acceptable under strict=False)

<success_criteria>

  • Wave 0 stubs cover every behavior in 07.2-VALIDATION.md Per-Task Verification Map (jti-claim, test-fakeredis, nbf-write × 4, nbf-check × 2, nbf-fail-open)
  • FakeRedis is reachable via request.app.state.redis in both test_auth_deps and test_admin_api fixtures
  • Wave 1 (Plan 02) and Wave 2 (Plan 03) can promote each stub to a passing assertion by editing only the test body — no new fixtures, no new files
  • Zero regressions: existing 373-test baseline from Phase 7.1 passes unchanged </success_criteria>
Create `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-01-SUMMARY.md` when done.

Required fields in SUMMARY: artifacts (4 test files), patterns_established (FakeRedis on test_auth_deps app.state, FakeRedis on admin_client fixture), patterns_to_avoid (do NOT redefine FakeRedis — import from tests.test_auth_api), provides (Wave 0 scaffolds for jti, NBF-check, NBF-write × 4, fail-open).