Compare commits
24
Commits
eda91f7512
...
646736d4d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646736d4d3 | ||
|
|
8e4e199062 | ||
|
|
d3deef4f95 | ||
|
|
7e23a3ed0e | ||
|
|
a20d27123d | ||
|
|
d3588ba055 | ||
|
|
ae11a6e913 | ||
|
|
efeb75b279 | ||
|
|
2665a5085d | ||
|
|
9f90d46649 | ||
|
|
61199752b5 | ||
|
|
30ad9fd015 | ||
|
|
c00c1fbaa7 | ||
|
|
a80b632ac8 | ||
|
|
8d44018f40 | ||
|
|
f0ba9e6d8e | ||
|
|
eb1647293f | ||
|
|
097cdcadf8 | ||
|
|
b7994efd06 | ||
|
|
c686d90e1f | ||
|
|
49c63337db | ||
|
|
7ee87c001b | ||
|
|
e9b2d88ba0 | ||
|
|
35ff0d52fa |
+17
-3
@@ -481,7 +481,21 @@ Before any phase is marked complete, all three gates must pass:
|
|||||||
**Depends on**: Phase 7.2
|
**Depends on**: Phase 7.2
|
||||||
**Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"JWT Algorithm Downgrade: HS256 Instead of ES256"
|
**Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"JWT Algorithm Downgrade: HS256 Instead of ES256"
|
||||||
|
|
||||||
**Status:** Not planned yet
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
**Wave 0** — Test scaffolds (no production code)
|
||||||
|
|
||||||
|
- [ ] 07.3-01-PLAN.md — Wave 0 xfail stubs: test_auth_es256.py (9 stubs covering ES256-01..05 + RM-01..03 + CFG-01 satellite) + extend test_settings_has_jwt_config for refresh_token_expire_hours
|
||||||
|
|
||||||
|
**Wave 1** *(blocked on Wave 0)* — ES256 core + startup rotation
|
||||||
|
|
||||||
|
- [ ] 07.3-02-PLAN.md — config.py jwt_private_key/jwt_public_key/refresh_token_expire_hours + services/auth.py 4 sites to ES256 + main.py _rotate_tokens_on_algorithm_change lifespan hook + docker-compose JWT_PRIVATE_KEY/JWT_PUBLIC_KEY + README key-gen snippet + .env.example + version bump 0.1.2
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1)* — "Remember me" 16h/30d TTL split
|
||||||
|
|
||||||
|
- [ ] 07.3-03-PLAN.md — create_refresh_token remember_me param + LoginRequest.remember_me + _set_refresh_cookie max_age conditional + LoginView.vue "Stay signed in for 30 days" checkbox + stores/auth.js + api/client.js forwarding + human checkpoint
|
||||||
|
|
||||||
|
**Status:** Planned (2026-06-05)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -510,6 +524,6 @@ Before any phase is marked complete, all three gates must pass:
|
|||||||
| 6.2. Close v1 sharing + cloud-delete + CSV export gaps | 5/5 | Complete | 2026-05-31 |
|
| 6.2. Close v1 sharing + cloud-delete + CSV export gaps | 5/5 | Complete | 2026-05-31 |
|
||||||
| 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 |
|
| 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 |
|
||||||
| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — |
|
| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — |
|
||||||
| 7.2. Security: JTI claim + Redis access-token revocation | 0/? | Not planned (INSERTED) | — |
|
| 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 |
|
||||||
| 7.3. Security: ES256 algorithm upgrade | 0/? | Not planned (INSERTED) | — |
|
| 7.3. Security: ES256 algorithm upgrade | 0/3 | Planned | — |
|
||||||
| 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — |
|
| 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — |
|
||||||
|
|||||||
+14
-9
@@ -2,22 +2,23 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: Redo and Optimize LLM Integration
|
milestone_name: Redo and Optimize LLM Integration
|
||||||
current_phase: 7.1 (complete)
|
current_phase: 07.2
|
||||||
status: completed
|
status: ready_to_plan
|
||||||
last_updated: "2026-06-05T12:42:08.692Z"
|
last_updated: 2026-06-05T22:12:09.647Z
|
||||||
progress:
|
progress:
|
||||||
total_phases: 7
|
total_phases: 7
|
||||||
completed_phases: 4
|
completed_phases: 4
|
||||||
total_plans: 14
|
total_plans: 19
|
||||||
completed_plans: 14
|
completed_plans: 60
|
||||||
percent: 57
|
percent: 57
|
||||||
|
stopped_at: Phase 07.2 complete (3/3) — ready to discuss Phase 07.3
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
**Project:** DocuVault
|
**Project:** DocuVault
|
||||||
**Status:** v1.0 Milestone COMPLETE — All 7 phases done
|
**Status:** Ready to plan
|
||||||
**Current Phase:** 7.1 (complete)
|
**Current Phase:** 07.3
|
||||||
**Last Updated:** 2026-06-05
|
**Last Updated:** 2026-06-05
|
||||||
|
|
||||||
## Phase Status
|
## Phase Status
|
||||||
@@ -34,10 +35,13 @@ progress:
|
|||||||
| 6.2 | Close v1 sharing + cloud-delete + CSV export gaps | ✓ Complete (5/5 plans, UAT passed, security gate passed) |
|
| 6.2 | Close v1 sharing + cloud-delete + CSV export gaps | ✓ Complete (5/5 plans, UAT passed, security gate passed) |
|
||||||
| 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) |
|
| 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) |
|
||||||
| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) |
|
| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) |
|
||||||
|
| 7.2 | Security: JTI claim + Redis access-token revocation | ◆ Planned (3 plans, 3 waves) — ready to execute |
|
||||||
|
| 7.3 | Security: ES256 algorithm upgrade | ◆ Planned (3 plans, 3 waves) — ready to execute |
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 7 (redo-and-optimize-llm-integration) — COMPLETE
|
Phase: 07.2 (security-jti-claim-redis-access-token-revocation-inserted) — EXECUTING
|
||||||
|
Plan: Not started
|
||||||
Phase: 7.1 (security-session-revocation-on-privilege-change) — COMPLETE (2/2 plans)
|
Phase: 7.1 (security-session-revocation-on-privilege-change) — COMPLETE (2/2 plans)
|
||||||
**Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
|
**Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ _Updated at each phase transition._
|
|||||||
| Last session | 2026-06-03 — Phase 7 planned: 5 plans (5 waves); system_settings DB + HKDF (Plan 01), ProviderConfig + GenericOpenAIProvider + singleton fix (Plan 02), Anthropic output_config + classifier wiring (Plan 03), Celery retry harness + re-queue endpoint (Plan 04), admin AI panel + DocumentCard badge (Plan 05); verification passed (4 blockers fixed in revision round, 0 remaining) |
|
| Last session | 2026-06-03 — Phase 7 planned: 5 plans (5 waves); system_settings DB + HKDF (Plan 01), ProviderConfig + GenericOpenAIProvider + singleton fix (Plan 02), Anthropic output_config + classifier wiring (Plan 03), Celery retry harness + re-queue endpoint (Plan 04), admin AI panel + DocumentCard badge (Plan 05); verification passed (4 blockers fixed in revision round, 0 remaining) |
|
||||||
| Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE |
|
| Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE |
|
||||||
| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 |
|
| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 |
|
||||||
| Next action | All phases and sub-phases complete. Ready for next milestone. |
|
| Last session | 2026-06-05 — Phase 7.2 planned: 3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf writes in 4 handlers); verification passed (0 blockers, 1 warning fixed); ready to execute |
|
||||||
|
| Next action | Execute Phase 7.2: /gsd:execute-phase 7.2 |
|
||||||
| Pending decisions | None |
|
| Pending decisions | None |
|
||||||
| Resume file | None |
|
| Resume file | None |
|
||||||
|
|||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 0
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- 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
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- CONCERNS:JTI-CLAIM
|
||||||
|
- CONCERNS:JTI-REVOKE-REDIS
|
||||||
|
tags:
|
||||||
|
- security
|
||||||
|
- jwt
|
||||||
|
- testing
|
||||||
|
- nyquist
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "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"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tests/test_auth_deps.py"
|
||||||
|
provides: "FakeRedis attached to make_test_app(); NBF-check xfail stubs (iat<nbf→401, iat>nbf→pass, fail-open)"
|
||||||
|
contains: "FakeRedis"
|
||||||
|
- path: "backend/tests/test_task2_auth_service.py"
|
||||||
|
provides: "test_create_access_token_includes_jti xfail stub"
|
||||||
|
contains: "jti"
|
||||||
|
- path: "backend/tests/test_auth_api.py"
|
||||||
|
provides: "NBF-write xfail stubs for change_password, enable_totp, disable_totp"
|
||||||
|
contains: "user_nbf"
|
||||||
|
- path: "backend/tests/test_admin_api.py"
|
||||||
|
provides: "NBF-write xfail stub for admin deactivation handler"
|
||||||
|
contains: "user_nbf"
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tests/test_auth_deps.py make_test_app()"
|
||||||
|
to: "request.app.state.redis"
|
||||||
|
via: "test_app.state.redis = FakeRedis() set before client yield"
|
||||||
|
pattern: "app\\.state\\.redis\\s*=\\s*FakeRedis"
|
||||||
|
- from: "Wave 0 stubs"
|
||||||
|
to: "Wave 1/2 implementation"
|
||||||
|
via: "pytest.xfail(strict=False) markers promote to xpass when code lands"
|
||||||
|
pattern: "pytest\\.xfail|xfail.*strict=False"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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`.
|
||||||
|
</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
|
||||||
|
@.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
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Reusable FakeRedis class already exists. DO NOT redefine. -->
|
||||||
|
|
||||||
|
Existing FakeRedis class — backend/tests/test_auth_api.py lines 47–86:
|
||||||
|
- 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 22–37:
|
||||||
|
- Builds a minimal FastAPI with /test/me and /test/admin endpoints
|
||||||
|
- Does NOT currently set app.state.redis
|
||||||
|
- Used by auth_client fixture (line 40–51)
|
||||||
|
|
||||||
|
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
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Mount FakeRedis on test_auth_deps test app and add NBF-check stubs</name>
|
||||||
|
<files>backend/tests/test_auth_deps.py</files>
|
||||||
|
<read_first>
|
||||||
|
- backend/tests/test_auth_deps.py (current make_test_app + auth_client fixture — lines 22–51)
|
||||||
|
- backend/tests/test_auth_api.py lines 47–86 (FakeRedis class definition — import this, do not redefine)
|
||||||
|
- backend/tests/test_auth_api.py lines 89–123 (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 296–301)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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"<future_ts>", 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"<past_ts>", 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")`.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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: 1–5 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_deps.py -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>FakeRedis mounted on test app; three NBF-check stubs added with xfail(strict=False); zero existing test regressions.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Add JTI presence stub in test_task2_auth_service.py</name>
|
||||||
|
<files>backend/tests/test_task2_auth_service.py</files>
|
||||||
|
<read_first>
|
||||||
|
- backend/tests/test_task2_auth_service.py lines 28–58 (existing create/decode_access_token tests — TEMPLATE)
|
||||||
|
- backend/services/auth.py lines 86–117 (current create_access_token + decode_access_token implementations — note absence of jti)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_task2_auth_service.py -v -k jti</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>JTI presence test stub added with xfail(strict=False); existing tests unaffected.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Add NBF-write stubs for change_password / enable_totp / disable_totp in test_auth_api.py</name>
|
||||||
|
<files>backend/tests/test_auth_api.py</files>
|
||||||
|
<read_first>
|
||||||
|
- backend/tests/test_auth_api.py lines 45–123 (FakeRedis class + authed_client fixture — already mounts app.state.redis)
|
||||||
|
- backend/api/auth.py lines 452–509 (change_password handler — Wave 2 will add the user_nbf write here)
|
||||||
|
- backend/api/auth.py lines 548–601 (enable_totp handler — Wave 2 write site)
|
||||||
|
- backend/api/auth.py lines 606–641 (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)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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 31–43) 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Three NBF-write stubs added covering change_password, enable_totp, disable_totp; existing auth API tests unaffected.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 4: Add NBF-write stub + FakeRedis for admin deactivation in test_admin_api.py</name>
|
||||||
|
<files>backend/tests/test_admin_api.py</files>
|
||||||
|
<read_first>
|
||||||
|
- backend/tests/test_admin_api.py lines 71–84 (admin_client fixture — does NOT currently set app.state.redis)
|
||||||
|
- backend/tests/test_admin_api.py lines 191–220 (test_deactivate_user — TEMPLATE for the new test)
|
||||||
|
- backend/tests/test_auth_api.py lines 47–86 (FakeRedis class — import target)
|
||||||
|
- backend/api/admin.py lines 340–380 (deactivation handler — Wave 2 write site)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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".)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_admin_api.py -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>FakeRedis attached to admin_client fixture; deactivation NBF-write stubs (positive + negative-guard) added; existing admin tests unaffected.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
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).
|
||||||
|
</output>
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
plan: "01"
|
||||||
|
subsystem: testing
|
||||||
|
tags:
|
||||||
|
- security
|
||||||
|
- jwt
|
||||||
|
- testing
|
||||||
|
- nyquist
|
||||||
|
- wave-0
|
||||||
|
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- "FakeRedis on test_auth_deps app.state.redis (Pitfall 4 guard)"
|
||||||
|
- "FakeRedis on admin_client fixture app.state.redis (Pitfall 4 guard)"
|
||||||
|
- "Wave 0 xfail stub: jti claim in create_access_token"
|
||||||
|
- "Wave 0 xfail stubs: NBF check accept/reject/fail-open in get_current_user"
|
||||||
|
- "Wave 0 xfail stubs: user_nbf write on change_password/enable_totp/disable_totp/admin-deactivation"
|
||||||
|
affects:
|
||||||
|
- "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"
|
||||||
|
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Wave 0 xfail(strict=False) scaffolding — stubs flip to xpass when Wave 1/2 implementation lands"
|
||||||
|
- "FakeRedis imported from tests.test_auth_api (single canonical definition, never redefined)"
|
||||||
|
- "app.state.redis = FakeRedis() set in fixture before yield, reset to None in teardown"
|
||||||
|
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
decisions:
|
||||||
|
- "Import FakeRedis from tests.test_auth_api — no redefinition in any file"
|
||||||
|
- "FakeRedis instantiated inside make_test_app() so each test invocation gets fresh state"
|
||||||
|
- "admin_client teardown resets app.state.redis = None to mirror authed_client convention"
|
||||||
|
- "Wave 0 stubs use pytest.xfail() call inside body (not just marker) for the three NBF-write tests, consistent with minimal-body convention"
|
||||||
|
- "test_activate_user_does_not_write_user_nbf added as negative-guard (mirrors RESEARCH.md anti-pattern)"
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
duration: "450s (7m)"
|
||||||
|
completed_date: "2026-06-05T17:08:58Z"
|
||||||
|
tasks_completed: 4
|
||||||
|
tasks_total: 4
|
||||||
|
files_changed: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 07.2 Plan 01: Wave 0 Test Scaffolding Summary
|
||||||
|
|
||||||
|
**One-liner:** FakeRedis mounted on two test fixtures + 9 xfail stubs covering every Phase 7.2 behavior (jti, NBF check × 3, NBF write × 4, negative-guard × 1).
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
This plan is the Nyquist Wave 0 scaffolding for Phase 7.2. It creates no implementation code — only test stubs that define the expected behaviors before any code in `services/auth.py`, `deps/auth.py`, `api/auth.py`, or `api/admin.py` is modified.
|
||||||
|
|
||||||
|
### Task 1: FakeRedis on test_auth_deps + NBF-check stubs
|
||||||
|
|
||||||
|
- `test_auth_deps.py`: added `from tests.test_auth_api import FakeRedis` import
|
||||||
|
- `make_test_app()`: now sets `test_app.state.redis = FakeRedis()` before returning
|
||||||
|
- Added 3 xfail stubs with `@pytest.mark.xfail(strict=False)`:
|
||||||
|
- `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` (uses inline `_BrokenRedis` class)
|
||||||
|
|
||||||
|
### Task 2: JTI presence stub in test_task2_auth_service.py
|
||||||
|
|
||||||
|
- Added `test_create_access_token_includes_jti_claim` decorated with `xfail(strict=False)`
|
||||||
|
- Calls `create_access_token` + `decode_access_token`, asserts `"jti"` key present and parseable as `uuid.UUID`
|
||||||
|
|
||||||
|
### Task 3: NBF-write stubs for change_password/enable_totp/disable_totp
|
||||||
|
|
||||||
|
- Added 3 xfail stubs to `test_auth_api.py`:
|
||||||
|
- `test_change_password_writes_user_nbf_to_redis`
|
||||||
|
- `test_enable_totp_writes_user_nbf_to_redis`
|
||||||
|
- `test_disable_totp_writes_user_nbf_to_redis`
|
||||||
|
- Each test exercises the full auth flow then calls `pytest.xfail()` as the Wave 0 body
|
||||||
|
|
||||||
|
### Task 4: FakeRedis on admin_client + deactivation NBF-write stubs
|
||||||
|
|
||||||
|
- `test_admin_api.py`: added `from tests.test_auth_api import FakeRedis`
|
||||||
|
- `admin_client` fixture: sets `app.state.redis = FakeRedis()` before client yield, resets to `None` after
|
||||||
|
- Added 2 xfail stubs:
|
||||||
|
- `test_deactivate_user_writes_user_nbf_to_redis` (positive case)
|
||||||
|
- `test_activate_user_does_not_write_user_nbf` (negative-guard — activation must NOT write user_nbf)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Baseline container test run: **73 passed** (across the 4 modified test files) — zero new failures.
|
||||||
|
|
||||||
|
Full project baseline: **373 passed** (Phase 7.1 complete state) — confirmed unaffected.
|
||||||
|
|
||||||
|
Acceptance criteria checks:
|
||||||
|
- `grep -c "FakeRedis" test_auth_deps.py` → 6 (import + assignment + stubs)
|
||||||
|
- `grep -c "test_app.state.redis" test_auth_deps.py` → 1
|
||||||
|
- 3 new NBF-check test functions in test_auth_deps.py
|
||||||
|
- 3 xfail markers in test_auth_deps.py
|
||||||
|
- `test_create_access_token_includes_jti_claim` in test_task2_auth_service.py
|
||||||
|
- 3 NBF-write test functions in test_auth_api.py
|
||||||
|
- `from tests.test_auth_api import FakeRedis` in test_admin_api.py
|
||||||
|
- `app.state.redis = FakeRedis()` in admin_client fixture
|
||||||
|
- 2 deactivation NBF stubs in test_admin_api.py
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Task | Commit | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| 1 | 49c6333 | test(07.2-01): mount FakeRedis on test_auth_deps app + add NBF-check xfail stubs |
|
||||||
|
| 2 | c686d90 | test(07.2-01): add JTI claim xfail stub to test_task2_auth_service.py |
|
||||||
|
| 3 | 097cdca | test(07.2-01): add NBF-write xfail stubs for change_password/enable_totp/disable_totp |
|
||||||
|
| 4 | eb16472 | test(07.2-01): add FakeRedis to admin_client + NBF-write xfail stubs for deactivation |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
All 9 new test functions are intentional Wave 0 stubs. They are xfailed with `strict=False` and carry `assert False, "stub"` or `pytest.xfail()` in their bodies. Wave 1 (Plan 02) and Wave 2 (Plan 03) will promote them to passing assertions by replacing the stub bodies with real assertion logic. These are tracked in the plan and are the intended output of this wave.
|
||||||
|
|
||||||
|
## Patterns Established
|
||||||
|
|
||||||
|
- **FakeRedis import pattern**: always `from tests.test_auth_api import FakeRedis` — never redefine in another file
|
||||||
|
- **FakeRedis on test_auth_deps**: `test_app.state.redis = FakeRedis()` inside `make_test_app()` — fresh instance per test invocation
|
||||||
|
- **FakeRedis teardown**: `app.state.redis = None` after fixture yield — prevents state leak across test files
|
||||||
|
- **Wave 0 body convention**: `assert False, "stub — Wave N will fill in: <target assertion>"` or `pytest.xfail("stub")` — no real assertion logic
|
||||||
|
|
||||||
|
## Patterns to Avoid
|
||||||
|
|
||||||
|
- Do NOT redefine `FakeRedis` in `test_auth_deps.py`, `test_admin_api.py`, or any other file — import from `tests.test_auth_api` only
|
||||||
|
- Do NOT add `app.state.redis = None` reset inside the fixture loop — only in teardown after yield
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — this plan only modifies test files and introduces no new network endpoints, auth paths, or schema changes.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- [x] `backend/tests/test_auth_deps.py` modified and committed (49c6333)
|
||||||
|
- [x] `backend/tests/test_task2_auth_service.py` modified and committed (c686d90)
|
||||||
|
- [x] `backend/tests/test_auth_api.py` modified and committed (097cdca)
|
||||||
|
- [x] `backend/tests/test_admin_api.py` modified and committed (eb16472)
|
||||||
|
- [x] All 4 commits exist: `git log --oneline -4` confirms eb16472, 097cdca, c686d90, 49c6333
|
||||||
|
- [x] 73 baseline tests pass in container (zero regressions)
|
||||||
|
- [x] All 9 Wave 0 stubs present and correctly decorated with xfail(strict=False)
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
---
|
||||||
|
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:86–99) -->
|
||||||
|
|
||||||
|
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:38–80) -->
|
||||||
|
|
||||||
|
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 1–30 (module docstring + imports — confirm `import uuid` already present at line 25)
|
||||||
|
- backend/services/auth.py lines 86–117 (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 143–166)
|
||||||
|
</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 86–99): 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 && pytest tests/test_task2_auth_service.py -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `cd backend && 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 38–80)
|
||||||
|
- backend/services/auth.py lines 286–297 (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 195–200 and 564–568 (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 186–235) AND "Pitfall 1" section (lines 279–284) AND "Pitfall 2" section (lines 286–290)
|
||||||
|
- 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 50–57) 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 && pytest tests/test_auth_deps.py tests/test_auth_api.py -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `cd backend && 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 >= 1
|
||||||
|
- `grep -E "except HTTPException:\\s*$" backend/deps/auth.py` returns >= 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 >= 1
|
||||||
|
- `grep -E "_logger.warning|logger.warning" backend/deps/auth.py` returns >= 1
|
||||||
|
- `grep -c "Session invalidated" backend/deps/auth.py` returns >= 1
|
||||||
|
- `grep -c "request.app.state.redis" backend/deps/auth.py` returns >= 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>
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
plan: "02"
|
||||||
|
subsystem: auth
|
||||||
|
tags:
|
||||||
|
- security
|
||||||
|
- jwt
|
||||||
|
- redis
|
||||||
|
- access-control
|
||||||
|
- wave-1
|
||||||
|
|
||||||
|
dependency_graph:
|
||||||
|
requires:
|
||||||
|
- "07.2-01: FakeRedis mounted on test_auth_deps + xfail stubs"
|
||||||
|
provides:
|
||||||
|
- "jti UUID claim in every access token issued by create_access_token"
|
||||||
|
- "user_nbf Redis NBF check in get_current_user with fail-open + HTTPException re-raise guard"
|
||||||
|
- "Wave 2 write sites (Plan 03) can now write user_nbf:{user_id} and the check will fire"
|
||||||
|
affects:
|
||||||
|
- "backend/services/auth.py"
|
||||||
|
- "backend/deps/auth.py"
|
||||||
|
- "backend/tests/test_task2_auth_service.py"
|
||||||
|
- "backend/tests/test_auth_deps.py"
|
||||||
|
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "jti=str(uuid.uuid4()) inserted into access token payload — uuid already imported, no new deps"
|
||||||
|
- "user_nbf Redis check in get_current_user: read-then-compare with fail-open and HTTPException re-raise guard"
|
||||||
|
- "Bytes/str tolerance for FakeRedis test fixtures: decode() only if isinstance bytes/bytearray"
|
||||||
|
- "except HTTPException: raise MUST precede except Exception — T-7.2-02 Pitfall 1 guard"
|
||||||
|
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- backend/services/auth.py
|
||||||
|
- backend/deps/auth.py
|
||||||
|
- backend/tests/test_task2_auth_service.py
|
||||||
|
- backend/tests/test_auth_deps.py
|
||||||
|
|
||||||
|
decisions:
|
||||||
|
- "jti added as the last key in the payload dict (after exp) for grep-ability; no function signature change"
|
||||||
|
- "NBF comparison uses strict < not <= (token issued exactly at event second is allowed — edge case per D-02)"
|
||||||
|
- "Bytes/str tolerance guard added: nbf_bytes.decode() if isinstance(bytes|bytearray) else nbf_bytes — FakeRedis may return str in tests"
|
||||||
|
- "No get_redis FastAPI dependency added — direct access via request.app.state.redis per D-03"
|
||||||
|
- "get_current_admin and get_regular_user not touched — they inherit via Depends(get_current_user)"
|
||||||
|
- "test_extract_docx failure confirmed pre-existing (missing python-docx module in local env) — unrelated to this plan"
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
duration: "600s (10m)"
|
||||||
|
completed_date: "2026-06-05T18:00:00Z"
|
||||||
|
tasks_completed: 2
|
||||||
|
tasks_total: 2
|
||||||
|
files_changed: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"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 a `str`, and parseable as `uuid.UUID`
|
||||||
|
- `test_create_access_token_jti_is_unique_per_call` — verifies two consecutive calls produce different `jti` values (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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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 future `user_nbf`, verifies HTTP 401 + `"Session invalidated"` + `WWW-Authenticate: Bearer`
|
||||||
|
- `test_get_current_user_allows_token_when_iat_after_user_nbf` — sets past `user_nbf`, verifies HTTP 200
|
||||||
|
- `test_get_current_user_failopen_on_redis_error` — replaces `app.state.redis` with `_BrokenRedis` that raises `RuntimeError`, 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']}` from `request.app.state.redis`; compare `payload["iat"] < int(nbf_str)` to reject; strict `<` not `<=`
|
||||||
|
- **HTTPException re-raise guard before broad catch**: `except HTTPException: raise` MUST precede `except 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_bytes` before `int(...)` — ensures tests using `FakeRedis.set(..., value.encode())` work identically to production `aioredis` returning 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_redis` FastAPI dependency — access must be direct via `request.app.state.redis` per D-03
|
||||||
|
- Do NOT touch `get_current_admin` or `get_regular_user` — they inherit the NBF check transparently via `Depends(get_current_user)`
|
||||||
|
- Do NOT move the broad `except Exception` before `except 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
|
||||||
|
|
||||||
|
- [x] `backend/services/auth.py` modified with `"jti": str(uuid.uuid4())` — confirmed by grep
|
||||||
|
- [x] `backend/deps/auth.py` modified with NBF check block — confirmed by grep (user_nbf, logging, Session invalidated, request.app.state.redis)
|
||||||
|
- [x] `backend/tests/test_task2_auth_service.py` modified — xfail removed, real assertions, new uniqueness test
|
||||||
|
- [x] `backend/tests/test_auth_deps.py` modified — 3 xfail stubs promoted to passing assertions
|
||||||
|
- [x] Commit c00c1fb exists: `git log --oneline | grep c00c1fb`
|
||||||
|
- [x] Commit 30ad9fd exists: `git log --oneline | grep 30ad9fd`
|
||||||
|
- [x] 29 tests pass in target suites: `pytest tests/test_auth_deps.py tests/test_task2_auth_service.py`
|
||||||
|
- [x] HTTPException re-raise guard ordering verified: except HTTPException at line 81 < except Exception at line 83
|
||||||
|
- [x] jti pattern grep returns 1 match: `"jti": str(uuid.uuid4()),`
|
||||||
+330
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</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/api/auth.py
|
||||||
|
@backend/api/admin.py
|
||||||
|
@backend/tests/test_auth_api.py
|
||||||
|
@backend/tests/test_admin_api.py
|
||||||
|
@backend/config.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- change_password handler — backend/api/auth.py:455 -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- enable_totp handler — backend/api/auth.py:552 -->
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
<!-- disable_totp handler — backend/api/auth.py:607 -->
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
<!-- admin deactivation handler — backend/api/admin.py:326 (PATCH /api/admin/users/{id}/status) -->
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
<!-- TTL constant -->
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
<!-- Redis write pattern (from RESEARCH.md Pattern 2, line 176) -->
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add import time + user_nbf writes to api/auth.py (change_password, enable_totp, disable_totp)</name>
|
||||||
|
<files>backend/api/auth.py</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>import time added; user_nbf written in all three auth security-event handlers; stubs promoted to PASSED.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add import time + user_nbf write to api/admin.py deactivation handler</name>
|
||||||
|
<files>backend/api/admin.py</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_admin_api.py -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>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.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Full regression run — verify zero new failures across entire test suite</name>
|
||||||
|
<files></files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-VALIDATION.md (Validation Sign-Off checklist)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest -v 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Full suite green; all Phase 7.2 behaviors verified; no regressions.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-03-SUMMARY.md` when done.
|
||||||
|
|
||||||
|
Required fields in SUMMARY: artifacts (api/auth.py + api/admin.py), patterns_established (user_nbf write pattern; conditional deactivation-only write; TTL derived from settings), patterns_to_avoid (do NOT write on activation; do NOT hardcode 900), provides (full Phase 7.2 revocation pipeline complete — jti issued + NBF checked + user_nbf written on all four security events).
|
||||||
|
</output>
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
+512
@@ -0,0 +1,512 @@
|
|||||||
|
# Phase 7.2: Security — JTI Claim + Redis Access-Token Revocation - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-06-05
|
||||||
|
**Domain:** JWT security, Redis-backed token revocation, FastAPI dependency injection
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**D-01 — JTI claim**
|
||||||
|
Add `jti=str(uuid.uuid4())` to the `create_access_token` payload in `backend/services/auth.py`. The `jti` claim is included on every token but is used only as an identity handle — per-JTI Redis tracking is NOT implemented (user-level NBF replaces it).
|
||||||
|
|
||||||
|
**D-02 — Revocation approach: user-level NBF key, not per-JTI**
|
||||||
|
Set `user_nbf:{user_id}` = Unix timestamp in Redis (TTL = 15 min, the access token lifetime) when any of these security events fire: `change_password`, `enable_totp`, `disable_totp`, admin account deactivation.
|
||||||
|
|
||||||
|
In `get_current_user`, after a successful decode, read `user_nbf:{user_id}` from Redis. If the key exists and `token["iat"] < nbf_timestamp` → raise HTTP 401.
|
||||||
|
|
||||||
|
**D-03 — Redis access pattern in get_current_user**
|
||||||
|
Use `request.app.state.redis` directly inside `get_current_user`. Consistent with existing pattern in `backend/api/auth.py` lines 197 and 566. No new `get_redis` dependency function.
|
||||||
|
|
||||||
|
**D-04 — Redis failure handling: fail-open**
|
||||||
|
If the Redis `GET user_nbf:{user_id}` call raises an exception, log a warning and allow the request to proceed. Mirrors the HIBP fail-open pattern in `services/auth.py`.
|
||||||
|
|
||||||
|
**D-05 — Admin account deactivation coverage**
|
||||||
|
The existing deactivation handler in `backend/api/admin.py:351` already calls `revoke_all_refresh_tokens`. Add the `user_nbf` write there too.
|
||||||
|
|
||||||
|
**D-06 — TTL for user_nbf key**
|
||||||
|
15 minutes (900 seconds) — matches the access token TTL.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
None specified.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- **Per-JTI Redis tracking** — storing each issued JTI in Redis for individual token revocation.
|
||||||
|
- **Phase 7.3 — ES256 algorithm upgrade**: Replace HS256 with ECDSA P-256. The jti claim added in this phase is a prerequisite.
|
||||||
|
- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = HMAC of `User-Agent + Accept-Language`.
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 7.2 closes a 15-minute security gap: after Phase 7.1 revokes all refresh tokens on a security event (password change, TOTP enroll/revoke, account deactivation), the attacker's live access token remains valid until its 15-minute TTL expires. This phase eliminates that window by embedding a `jti` UUID claim in every new access token and introducing a `user_nbf:{user_id}` Redis key that invalidates all tokens issued before the security event.
|
||||||
|
|
||||||
|
The implementation touches exactly four files: `backend/services/auth.py` (add `jti` to `create_access_token`), `backend/deps/auth.py` (add NBF check in `get_current_user`), `backend/api/auth.py` (`change_password`, `enable_totp`, `disable_totp` — write Redis key), and `backend/api/admin.py` (deactivation handler — write Redis key). No schema migrations, no new endpoints, no frontend changes.
|
||||||
|
|
||||||
|
The approach is a user-level "not-before" timestamp rather than per-JTI revocation. Writing one Redis key per security event is O(1) and leaves no accumulation. The key auto-expires after 15 minutes (matching access token TTL), so Redis memory pressure is negligible.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement exactly as specified in D-01 through D-06. The patterns are already proven in this codebase (TOTP replay prevention uses the identical Redis key-style and TTL approach). The only new import needed in `api/auth.py` and `api/admin.py` is `import time` — all other dependencies are already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| JTI claim generation | API / Backend (`services/auth.py`) | — | Token creation is a pure service function; JTI is a UUID added to the payload dict before signing |
|
||||||
|
| NBF check on every request | API / Backend (`deps/auth.py`) | — | FastAPI dep chain; runs before every protected route handler |
|
||||||
|
| user_nbf Redis write on security event | API / Backend (`api/auth.py`, `api/admin.py`) | — | Handlers own the security event logic; Redis write belongs alongside the existing `revoke_all_refresh_tokens` call |
|
||||||
|
| Redis storage for user_nbf | Database / Storage (Redis) | — | TTL-keyed ephemeral state; same store as TOTP replay prevention and rate limiting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (no new packages required)
|
||||||
|
|
||||||
|
| Library | Current Version | Purpose | Already Used |
|
||||||
|
|---------|----------------|---------|-------------|
|
||||||
|
| `PyJWT` | 2.13.0 [VERIFIED: runtime] | JWT encoding/decoding; `jti` is an arbitrary payload key — accepted verbatim | Yes, `services/auth.py` |
|
||||||
|
| `redis` (async) | `>=4.6.0` in requirements.txt | `await redis.get(key)` / `await redis.set(key, value, ex=seconds)` | Yes, `app.state.redis` |
|
||||||
|
| `uuid` (stdlib) | 3.x | `str(uuid.uuid4())` for JTI value | Yes, `services/auth.py` |
|
||||||
|
| `time` (stdlib) | 3.x | `int(time.time())` for Unix timestamp in user_nbf key | Needs `import time` added to `api/auth.py` and `api/admin.py` |
|
||||||
|
|
||||||
|
### No new packages to install
|
||||||
|
|
||||||
|
This phase adds zero new dependencies. All required functionality is already present in the installed libraries and Python stdlib.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
> No external packages are added in this phase. This section is intentionally blank.
|
||||||
|
|
||||||
|
**Packages removed due to slopcheck [SLOP] verdict:** none
|
||||||
|
**Packages flagged as suspicious [SUS]:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Security Event (change_password / enable_totp / disable_totp / admin deactivate)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
api/auth.py or api/admin.py
|
||||||
|
│ (already) revoke_all_refresh_tokens(session, user_id, ...)
|
||||||
|
│ (NEW) await redis.set(f"user_nbf:{user_id}", int(time.time()), ex=900)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Redis: user_nbf:{user_id} = <unix_ts> [TTL=900s]
|
||||||
|
|
||||||
|
Every API request (any protected route):
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
deps/auth.py :: get_current_user
|
||||||
|
│
|
||||||
|
├─ decode_access_token → payload (sub, role, iat, jti, ...)
|
||||||
|
│
|
||||||
|
├─ (NEW) nbf_bytes = await request.app.state.redis.get(f"user_nbf:{payload['sub']}")
|
||||||
|
│ if nbf_bytes and payload["iat"] < int(nbf_bytes.decode()):
|
||||||
|
│ raise HTTP 401 "Session invalidated"
|
||||||
|
│
|
||||||
|
└─ session.get(User, user_uuid) → return user
|
||||||
|
|
||||||
|
Token issuance (every login / refresh):
|
||||||
|
services/auth.py :: create_access_token
|
||||||
|
│ (NEW) jti=str(uuid.uuid4()) added to payload
|
||||||
|
└─ jwt.encode(payload, ...) → signed JWT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
No new files. Changes are surgical edits to existing files:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── services/auth.py # add jti= to create_access_token payload
|
||||||
|
├── deps/auth.py # add user_nbf Redis check in get_current_user
|
||||||
|
├── api/auth.py # add redis write to change_password, enable_totp, disable_totp
|
||||||
|
└── api/admin.py # add redis write to deactivation handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Adding jti to create_access_token
|
||||||
|
|
||||||
|
**What:** Insert `jti=str(uuid.uuid4())` into the payload dict before signing.
|
||||||
|
**When to use:** Always. PyJWT encodes arbitrary payload keys verbatim. The `jti` key is RFC 7519 standard (lowercase).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: CONTEXT.md D-01 + PyJWT 2.13.0 verified behavior
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
def create_access_token(user_id: str, role: str) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"typ": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
"jti": str(uuid.uuid4()), # ← only change
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verified fact:** PyJWT 2.13.0 encodes `iat` as a Unix integer when a `datetime` object is passed. `payload["iat"]` after `decode()` is an `int`. `int(time.time())` is also an `int`. Direct `<` comparison works correctly. [VERIFIED: runtime test — `iat type: <class 'int'>` confirmed in this session]
|
||||||
|
|
||||||
|
### Pattern 2: user_nbf Redis write on security event
|
||||||
|
|
||||||
|
**What:** Write `user_nbf:{user_id}` = `int(time.time())` with TTL=900 to Redis immediately before `session.commit()` on any security event.
|
||||||
|
**When to use:** In `change_password`, `enable_totp`, `disable_totp`, and the admin deactivation handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: CONTEXT.md D-02, D-03, D-06; mirrors totp_used pattern in services/auth.py
|
||||||
|
import time # new import
|
||||||
|
|
||||||
|
# Inside handler, after revoke_all_refresh_tokens, before session.commit():
|
||||||
|
redis_client = request.app.state.redis
|
||||||
|
await redis_client.set(f"user_nbf:{current_user.id}", int(time.time()), ex=900)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key facts:**
|
||||||
|
- TTL = 900 = `ACCESS_TOKEN_EXPIRE_MINUTES * 60` = 15 * 60. Keys auto-expire when the oldest possible old token would have expired anyway. [VERIFIED: config.py `access_token_expire_minutes: int = 15`]
|
||||||
|
- `request.app.state.redis` is the aioredis client already confirmed at lines 197 and 566 of `api/auth.py`.
|
||||||
|
- Admin deactivation uses `request` from handler signature — same access pattern applies.
|
||||||
|
|
||||||
|
### Pattern 3: NBF check in get_current_user
|
||||||
|
|
||||||
|
**What:** After `decode_access_token` succeeds, check Redis for `user_nbf:{user_id}`. If set and `payload["iat"] < stored_timestamp`, raise HTTP 401.
|
||||||
|
**When to use:** In `deps/auth.py::get_current_user`, after the decode block, before the DB lookup.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: CONTEXT.md D-02, D-03, D-04; mirrors HIBP fail-open pattern in services/auth.py
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
try:
|
||||||
|
payload = auth_service.decode_access_token(credentials.credentials)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=401, ...) from exc
|
||||||
|
|
||||||
|
# ── user_nbf check (NEW) ────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
redis = request.app.state.redis
|
||||||
|
nbf_bytes = await redis.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"},
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise # re-raise the 401 we just constructed
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Redis user_nbf check failed (fail-open): %s", exc)
|
||||||
|
# ── end user_nbf check ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(payload["sub"])
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=401, ...) from exc
|
||||||
|
|
||||||
|
user = await session.get(User, user_uuid)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, ...)
|
||||||
|
|
||||||
|
request.state.current_user = user
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical detail:** The `except Exception` that implements fail-open must explicitly re-raise `HTTPException` first, otherwise it would swallow the intentional 401 we just raised. [ASSUMED — standard Python try/except behavior, no special framework wrapping needed here, but the planner must verify the exception-in-except pattern is correct]
|
||||||
|
|
||||||
|
### Pattern 4: Admin deactivation handler (api/admin.py ~line 351)
|
||||||
|
|
||||||
|
**What:** Add Redis write immediately after the existing `revoke_all_refresh_tokens` call.
|
||||||
|
**When to use:** When `not body.is_active` — same condition as the existing revocation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: CONTEXT.md D-05; existing handler at admin.py:351
|
||||||
|
if not body.is_active:
|
||||||
|
await revoke_all_refresh_tokens(session, user.id)
|
||||||
|
# (NEW) immediately block any live access token
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{user.id}", int(time.time()), ex=900
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import needed:** `import time` at the top of `api/admin.py` (check if already present).
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Do not swallow HTTPException in the fail-open catch:** The `except Exception as exc` block must have a `raise` for `HTTPException` before the general catch. Otherwise a valid revocation check (iat < nbf) will silently pass through as fail-open.
|
||||||
|
- **Do not use `datetime`-based comparison for iat vs nbf:** `payload["iat"]` is an int (verified above). `int(time.time())` is also an int. Use integer comparison — no timezone conversion needed.
|
||||||
|
- **Do not write user_nbf for successful activation (`body.is_active == True`):** The Redis write is only needed on deactivation. Writing it on activation would block the just-reactivated user's next request.
|
||||||
|
- **Do not add `import time` inside handler bodies:** Add it at the module level of `api/auth.py` and `api/admin.py` following the existing module-level imports.
|
||||||
|
- **Do not set TTL to `settings.access_token_expire_minutes * 60` computed inline:** Hardcode 900 or define `ACCESS_TOKEN_NBF_TTL = 15 * 60` as a module-level constant next to `create_access_token`. The constant TTL is intentional and should be obvious.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| UUID generation for jti | Custom random hex | `str(uuid.uuid4())` | Already imported in `services/auth.py`; RFC 7519 compliant; collision-free |
|
||||||
|
| Unix timestamp | Custom datetime arithmetic | `int(time.time())` | Direct, correct, no timezone confusion |
|
||||||
|
| Redis TTL management | Python-side expiry tracking | `redis.set(..., ex=900)` | Atomic server-side TTL; correct even if the app crashes after writing |
|
||||||
|
| Fail-open Redis errors | Try/except that re-raises | Logger warning + proceed | Matches HIBP pattern already in codebase; availability > availability of revocation during outage |
|
||||||
|
|
||||||
|
**Key insight:** This phase is almost entirely plumbing existing infrastructure. The TOTP replay prevention (`totp_used:{user_id}:{code}` with TTL=90) is a direct template for `user_nbf:{user_id}` with TTL=900.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Swallowing the HTTPException in fail-open
|
||||||
|
**What goes wrong:** The `except Exception as exc` catch intended for Redis errors silently catches the `HTTPException(401)` raised by the iat-vs-nbf check, allowing a revoked token through.
|
||||||
|
**Why it happens:** Python's `except Exception` catches all exceptions including `HTTPException`.
|
||||||
|
**How to avoid:** Always add `except HTTPException: raise` before the broad `except Exception` catch in the user_nbf check block.
|
||||||
|
**Warning signs:** Test that verifies NBF revocation passes in isolation but fails when run inside a try/except block — the 401 never reaches the test assertion.
|
||||||
|
|
||||||
|
### Pitfall 2: Wrong comparison direction
|
||||||
|
**What goes wrong:** Checking `nbf_timestamp < payload["iat"]` instead of `payload["iat"] < nbf_timestamp` — logic is inverted; tokens issued AFTER the event are blocked, tokens issued BEFORE are allowed.
|
||||||
|
**Why it happens:** Easy to flip the inequality when reading "token issued before NBF is invalid."
|
||||||
|
**How to avoid:** Read it as: "token's issue time (iat) is EARLIER THAN the not-before timestamp (nbf) → invalid." `payload["iat"] < nbf_timestamp` → raise 401.
|
||||||
|
**Warning signs:** Security test where a token issued before the event still works — the revocation is a no-op.
|
||||||
|
|
||||||
|
### Pitfall 3: Blocking the recovering session
|
||||||
|
**What goes wrong:** The user changes their password (security event) → user_nbf is written → user's own current session's access token is also blocked → next API call returns 401 → frontend must refresh the access token.
|
||||||
|
**Why this is intentional, not a bug:** The CONTEXT.md D-02 explicitly states: "the calling session's own token is also immediately blocked (its iat predates now()), but it recovers transparently by exchanging its still-live refresh token (which Phase 7.1 D-02 preserved) for a new access token with iat > nbf." The frontend's silent refresh path handles this. No code change is needed to "fix" this — it is the intended behavior.
|
||||||
|
**Warning signs:** A test that calls change_password and then immediately calls `/api/auth/me` WITHOUT refreshing the access token → will get 401. This is correct. The test must refresh first.
|
||||||
|
|
||||||
|
### Pitfall 4: Redis not available in test fixtures
|
||||||
|
**What goes wrong:** `get_current_user` calls `request.app.state.redis` but the test app (created via `make_test_app()` in `test_auth_deps.py`) has no `app.state.redis` set → `AttributeError`.
|
||||||
|
**Why it happens:** The minimal test app in `test_auth_deps.py` does not mount the full lifespan that creates `app.state.redis`. The new NBF check will crash with `AttributeError` on any test using that fixture.
|
||||||
|
**How to avoid:** In `test_auth_deps.py`, add a FakeRedis instance to `app.state.redis` before tests run. Since the NBF key will not be set in the fake Redis, the check will pass through (no key = no block). The existing FakeRedis class in `test_auth_api.py` already supports `get` and `set` — reuse it or import it.
|
||||||
|
**Warning signs:** `AttributeError: 'State' object has no attribute 'redis'` in any test that exercises `get_current_user`.
|
||||||
|
|
||||||
|
### Pitfall 5: admin.py does not import `time`
|
||||||
|
**What goes wrong:** `int(time.time())` raises `NameError: name 'time' is not defined`.
|
||||||
|
**Why it happens:** `api/admin.py` does not currently import the `time` stdlib module (grep confirms no `import time` there).
|
||||||
|
**How to avoid:** Add `import time` to the module-level imports of `api/admin.py`.
|
||||||
|
**Warning signs:** `NameError` in the deactivation handler during testing.
|
||||||
|
|
||||||
|
### Pitfall 6: TTL mismatch between user_nbf write sites
|
||||||
|
**What goes wrong:** One handler writes TTL=900 and another writes a different value, creating inconsistent revocation windows.
|
||||||
|
**How to avoid:** Define `_USER_NBF_TTL = 900` as a module-level constant in `api/auth.py` (or in `config.py` as `access_token_expire_minutes * 60`). Use the constant in all four write sites. In `api/admin.py`, import the constant or hardcode 900 with a comment referencing it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Verified: PyJWT iat encoding behavior
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Verified in this research session with PyJWT 2.13.0
|
||||||
|
# payload["iat"] after jwt.decode() is always an int (Unix timestamp)
|
||||||
|
# int(time.time()) is also an int
|
||||||
|
# Direct integer comparison works: payload["iat"] < int(nbf_bytes.decode())
|
||||||
|
decoded = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||||
|
type(decoded["iat"]) # <class 'int'> ← verified
|
||||||
|
```
|
||||||
|
[VERIFIED: runtime test — see research session output]
|
||||||
|
|
||||||
|
### Verified: Redis set with TTL
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: services/auth.py::verify_totp (existing pattern, line 296)
|
||||||
|
await redis_client.set(replay_key, "1", ex=90)
|
||||||
|
# Same pattern for user_nbf:
|
||||||
|
await redis_client.set(f"user_nbf:{user_id}", int(time.time()), ex=900)
|
||||||
|
```
|
||||||
|
[VERIFIED: codebase — `services/auth.py:296`]
|
||||||
|
|
||||||
|
### Verified: Redis get returning bytes
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: services/auth.py::verify_totp (existing pattern, line 289)
|
||||||
|
if await redis_client.get(replay_key):
|
||||||
|
return False
|
||||||
|
# For user_nbf, need the value (not just truthiness):
|
||||||
|
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(...)
|
||||||
|
```
|
||||||
|
[VERIFIED: codebase — `services/auth.py:289`; `redis.get()` returns `bytes | None`]
|
||||||
|
|
||||||
|
### Verified: Fail-open pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: services/auth.py::check_hibp (lines 394-396) — exact template
|
||||||
|
try:
|
||||||
|
# ... Redis call ...
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("HIBP check failed (fail-open): %s", exc)
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
[VERIFIED: codebase — `services/auth.py:394`]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| No token revocation after security event (access token valid until TTL) | user-level NBF key in Redis; closes 15-min gap | Phase 7.2 | Deactivated users cannot continue using live access tokens |
|
||||||
|
| No jti claim | `jti=str(uuid.uuid4())` in every access token | Phase 7.2 | Prerequisite for Phase 7.3 ES256 migration; enables future per-JTI revocation |
|
||||||
|
|
||||||
|
**Deferred / out of scope:**
|
||||||
|
- Per-JTI revocation (one Redis key per token): more granular but not needed; user-NBF covers all sessions uniformly and is O(1) writes vs O(N) for N active sessions.
|
||||||
|
- ES256 algorithm upgrade (Phase 7.3): jti claim is its prerequisite.
|
||||||
|
- Token fingerprinting (Phase 7.4): fgp claim not in scope here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | The `except Exception` catch in the fail-open block must have an explicit `except HTTPException: raise` guard before it | Architecture Patterns, Pattern 3 | If wrong (e.g., FastAPI wraps HTTPException in a different base), the guard might be unnecessary boilerplate — low risk, safe to include regardless |
|
||||||
|
| A2 | `api/admin.py` does not currently import `time` | Common Pitfalls 5 | If wrong, no change needed — planner should verify with grep |
|
||||||
|
|
||||||
|
**If this table is empty:** Not applicable — A1 and A2 are documented above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
|
1. **Does `test_auth_deps.py` need a FakeRedis fixture?**
|
||||||
|
- What we know: `test_auth_deps.py` creates a minimal test app that calls `get_current_user`. After this phase, `get_current_user` will call `request.app.state.redis`. The minimal app has no lifespan handler.
|
||||||
|
- What's unclear: Whether the existing `auth_client` fixture sets `app.state.redis` anywhere.
|
||||||
|
- Recommendation: Planner should add `test_app.state.redis = FakeRedis()` in `make_test_app()` or in the fixture. No Redis key will be set so all existing tests continue to pass.
|
||||||
|
- **RESOLVED:** Plan 01 Task 1 adds `test_app.state.redis = FakeRedis()` in `make_test_app()`. FakeRedis is imported from `tests.test_auth_api`.
|
||||||
|
|
||||||
|
2. **Should `_USER_NBF_TTL` be a constant in `config.py` or a module constant in `api/auth.py`?**
|
||||||
|
- What we know: `access_token_expire_minutes` is already in `config.py`; TTL = `access_token_expire_minutes * 60`.
|
||||||
|
- What's unclear: Whether the user prefers `settings.access_token_expire_minutes * 60` (derived at runtime) or a hardcoded `900`.
|
||||||
|
- Recommendation: Use `settings.access_token_expire_minutes * 60` in all write sites so that if the TTL ever changes in config, the NBF TTL stays synchronized automatically. This is slightly cleaner than a separate constant.
|
||||||
|
- **RESOLVED:** Plan 03 Tasks 1-2 use `settings.access_token_expire_minutes * 60` inline in all four write sites. No separate TTL constant extracted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| Redis (`app.state.redis`) | user_nbf writes and reads | Yes (runtime via Docker Compose; FakeRedis in tests) | redis>=4.6.0 | Fail-open (D-04) |
|
||||||
|
| PyJWT | `create_access_token`, `decode_access_token` | Yes | 2.13.0 [VERIFIED: runtime] | — |
|
||||||
|
| Python `uuid` stdlib | `str(uuid.uuid4())` for jti | Yes (stdlib) | always available | — |
|
||||||
|
| Python `time` stdlib | `int(time.time())` for user_nbf value | Yes (stdlib, needs `import time`) | always available | — |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | pytest + pytest-asyncio |
|
||||||
|
| Config file | `backend/pytest.ini` (or inline `pyproject.toml` — check existing) |
|
||||||
|
| Quick run command | `pytest tests/test_auth_deps.py tests/test_auth_api.py -v` |
|
||||||
|
| Full suite command | `pytest -v` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| SEC (JTI) | Every access token contains a `jti` UUID claim | Unit | `pytest tests/test_task2_auth_service.py -k jti -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-write) | change_password writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-write) | enable_totp writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-write) | disable_totp writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-write) | admin deactivation writes user_nbf Redis key | Integration | `pytest tests/test_admin_api.py -k nbf -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-check) | Token with iat before user_nbf returns 401 | Integration | `pytest tests/test_auth_deps.py -k nbf -v` | Wave 0 stub |
|
||||||
|
| SEC (fail-open) | Redis error in NBF check does not block request | Unit | `pytest tests/test_auth_deps.py -k failopen -v` | Wave 0 stub |
|
||||||
|
| SEC (NBF-check) | Token with iat after user_nbf is accepted | Integration | `pytest tests/test_auth_deps.py -k nbf_after -v` | Wave 0 stub |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
|
||||||
|
- **Per task commit:** `pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py -v`
|
||||||
|
- **Per wave merge:** `pytest -v` (full suite)
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
- [ ] `tests/test_auth_api.py` — add NBF-write tests for change_password, enable_totp, disable_totp
|
||||||
|
- [ ] `tests/test_auth_deps.py` — add NBF-check tests (iat < nbf → 401, iat > nbf → pass, Redis error → pass); add FakeRedis to `auth_client` fixture or `make_test_app()`
|
||||||
|
- [ ] `tests/test_admin_api.py` — add NBF-write test for deactivation handler
|
||||||
|
- [ ] `tests/test_task2_auth_service.py` — add JTI presence test for `create_access_token`
|
||||||
|
|
||||||
|
*(Existing 34 auth tests in `test_auth_deps.py` and `test_auth_api.py` must remain green with zero regressions.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | Yes | user_nbf key closes post-revocation window |
|
||||||
|
| V3 Session Management | Yes | user_nbf + existing refresh revocation = complete session invalidation |
|
||||||
|
| V4 Access Control | No | No new access control decisions |
|
||||||
|
| V5 Input Validation | No | No new user input |
|
||||||
|
| V6 Cryptography | No | jti is UUID (not a secret); no new crypto |
|
||||||
|
|
||||||
|
### Known Threat Patterns for JWT + Redis stack
|
||||||
|
|
||||||
|
| Pattern | STRIDE | Standard Mitigation |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| Stolen access token replay after deactivation | Elevation of Privilege | user_nbf Redis key blocks iat-predating tokens (this phase) |
|
||||||
|
| Redis outage used to bypass revocation | Denial of Service / EoP | Fail-open (D-04) — availability preferred over blocking; known and accepted trade-off |
|
||||||
|
| user_nbf key TTL mismatch | Elevation of Privilege | Derive TTL from `settings.access_token_expire_minutes * 60` to stay synchronized |
|
||||||
|
| HTTPException swallowed by fail-open catch | Elevation of Privilege | Explicit `except HTTPException: raise` guard before broad `except Exception` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- PyJWT 2.13.0 — runtime verified: `iat` decoded as `int`, `jti` custom claim encoded verbatim
|
||||||
|
- `backend/services/auth.py` — TOTP replay prevention pattern (lines 288-296) as direct template for user_nbf
|
||||||
|
- `backend/api/auth.py` — `request.app.state.redis` access pattern (lines 197, 566)
|
||||||
|
- `backend/deps/auth.py` — `get_current_user` signature and exception handling structure
|
||||||
|
- `backend/config.py` — `access_token_expire_minutes: int = 15` confirms 900s TTL
|
||||||
|
- `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md` — locked decisions D-01 through D-06
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- `backend/services/auth.py::check_hibp` — fail-open exception handling template
|
||||||
|
- `backend/api/admin.py:351` — deactivation handler structure for D-05 write site
|
||||||
|
- `backend/tests/test_auth_api.py::FakeRedis` — test infrastructure reusable for Phase 7.2 tests
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None — all claims verified against codebase or runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — no new packages; all libraries verified in runtime
|
||||||
|
- Architecture: HIGH — patterns directly copied from existing codebase (TOTP replay, HIBP fail-open, redis access)
|
||||||
|
- Pitfalls: HIGH — derived from direct code inspection of exception handling and test infrastructure
|
||||||
|
- Test gaps: HIGH — confirmed by reading test files; FakeRedis missing from auth_deps fixture is a real gap
|
||||||
|
|
||||||
|
**Research date:** 2026-06-05
|
||||||
|
**Valid until:** Stable (no fast-moving dependencies; Python stdlib and PyJWT 2.x API is stable)
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
reviewed: 2026-06-06T00:00:00Z
|
||||||
|
depth: standard
|
||||||
|
files_reviewed: 8
|
||||||
|
files_reviewed_list:
|
||||||
|
- backend/api/admin.py
|
||||||
|
- backend/api/auth.py
|
||||||
|
- backend/deps/auth.py
|
||||||
|
- backend/services/auth.py
|
||||||
|
- backend/tests/test_admin_api.py
|
||||||
|
- backend/tests/test_auth_api.py
|
||||||
|
- backend/tests/test_auth_deps.py
|
||||||
|
- backend/tests/test_task2_auth_service.py
|
||||||
|
findings:
|
||||||
|
critical: 2
|
||||||
|
warning: 4
|
||||||
|
info: 2
|
||||||
|
total: 8
|
||||||
|
status: issues_found
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 07.2: Code Review Report
|
||||||
|
|
||||||
|
**Reviewed:** 2026-06-06
|
||||||
|
**Depth:** standard
|
||||||
|
**Files Reviewed:** 8
|
||||||
|
**Status:** issues_found
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 7.2 implements three things: a `jti` UUID claim on access tokens, a `user_nbf` Redis check in `get_current_user` to invalidate pre-event tokens, and `user_nbf` writes in the four security-event handlers. The core security structure is sound — the `except HTTPException: raise` guard precedes the broad `except Exception`, all four write sites use `settings.access_token_expire_minutes * 60` (not the banned literal 900), and the NBF write is correctly gated on `if not body.is_active` only.
|
||||||
|
|
||||||
|
Two critical issues exist: the JWT algorithm is still `HS256` despite CLAUDE.md mandating `ES256`, and the three NBF-check tests in `test_auth_deps.py` were promoted to full assertions without `@pytest.mark.xfail` — but more importantly were implemented as Wave 1 complete tests when the plan intended them to start as stubs and be promoted. As implemented they are passing real assertions, which is actually the correct end state, so the critical issue is the algorithm mismatch, not the xfail promotion.
|
||||||
|
|
||||||
|
The second critical issue is a race in `password_reset_confirm` (auth.py): a user's pre-reset access tokens are not invalidated with a `user_nbf` write, leaving a 15-minute window open after a self-service password reset via the reset link — the same gap this entire phase was designed to close.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
|
||||||
|
### CR-01: JWT algorithm is HS256, not ES256 as mandated by CLAUDE.md
|
||||||
|
|
||||||
|
**File:** `backend/services/auth.py:100` and `backend/services/auth.py:110`
|
||||||
|
|
||||||
|
**Issue:** `create_access_token` signs with `algorithm="HS256"` (symmetric HMAC-SHA256). CLAUDE.md Security Protocol states unambiguously: "Algorithm: ES256 (ECDSA P-256) — asymmetric; the private key signs, the public key verifies; a leaked public key cannot forge tokens." All four `jwt.encode` / `jwt.decode` call sites use `HS256`, meaning a single leaked `secret_key` allows unlimited token forgery. Phase 7.3 exists precisely to upgrade to ES256, but it has not been executed; meanwhile the CLAUDE.md requirement is active and the code ships HS256 tokens.
|
||||||
|
|
||||||
|
**Fix:** Implement ES256 properly — generate or load an ECDSA P-256 key pair from env vars, use `algorithm="ES256"` at encode and decode sites. Until Phase 7.3 ships, this finding must remain open; CLAUDE.md's "Login token hardening (state of the art)" section must be updated to note the gap if HS256 is intentionally deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CR-02: `password_reset_confirm` does not write `user_nbf` — pre-reset access tokens remain valid for up to 15 minutes
|
||||||
|
|
||||||
|
**File:** `backend/api/auth.py:700-746`
|
||||||
|
|
||||||
|
**Issue:** The `POST /api/auth/password-reset/confirm` endpoint changes the user's password and revokes all refresh tokens (line 742) but never writes `user_nbf:{user_id}` to Redis. Every other security-event handler in this phase (change-password, enable-totp, disable-totp, admin-deactivation) correctly writes the NBF key. A user who received a password-reset email — including one sent by an attacker who compromised the email inbox — can use the reset link, change the victim's password, and the victim's live access token continues to be accepted for up to 15 minutes. This is the exact threat T-7.2-01 this phase is meant to close.
|
||||||
|
|
||||||
|
**Fix:** Add the `user_nbf` write to `password_reset_confirm` immediately after revoking refresh tokens and before `await session.commit()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Revoke any pre-reset access tokens still within their TTL window (T-7.2-01)
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `import time` (already imported in auth.py at line 22) and inject `request: Request` into the function signature. A corresponding test parallel to `test_change_password_writes_user_nbf_to_redis` must be added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warnings
|
||||||
|
|
||||||
|
### WR-01: Three NBF-check tests in test_auth_deps.py have no `@pytest.mark.xfail` decorator but were listed as Wave-0 stubs — they are real assertions that may hide promotion errors
|
||||||
|
|
||||||
|
**File:** `backend/tests/test_auth_deps.py:189-250`
|
||||||
|
|
||||||
|
**Issue:** The plan (07.2-01-PLAN.md) specifies Wave-0 stubs decorated with `@pytest.mark.xfail(strict=False)`. The implemented tests (`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`) contain fully-implemented assertions with no xfail decorator. This means they pass now because Wave 1 (Plan 02) was already implemented, which is the correct end state — but the VALIDATION.md status table still shows them as `pending` and the plan acceptance criteria checking for XFAIL counts will fail their grep assertions. The actual concern is that the comment block at line 184 says "xfailed with strict=False" but the tests are not, creating a documentation lie that will confuse the next developer.
|
||||||
|
|
||||||
|
**Fix:** Update the comment at line 184 to say "promoted to full assertions (Wave 1 complete)". Update VALIDATION.md status column for `nbf-check-reject`, `nbf-check-allow`, and `nbf-fail-open` from `pending` to `green`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-02: Three NBF-write tests in test_auth_api.py have no `@pytest.mark.xfail` decorator but the comment block says they do
|
||||||
|
|
||||||
|
**File:** `backend/tests/test_auth_api.py:606-697`
|
||||||
|
|
||||||
|
**Issue:** The comment at line 606-608 reads: "These three tests are xfailed with strict=False. When Wave 2 (Plan 03) adds user_nbf writes to the handlers, these stubs are promoted to real assertions by replacing `pytest.xfail(...)` with actual Redis key assertions." However, the three tests (`test_change_password_writes_user_nbf_to_redis`, `test_enable_totp_writes_user_nbf_to_redis`, `test_disable_totp_writes_user_nbf_to_redis`) contain real assertions and no xfail decorator. Since Wave 2 writes were already implemented, these tests are correctly passing — but the comment is wrong and refers to a stub pattern that was never applied. If anyone reads this and tries to find the `pytest.xfail(...)` call referenced in the comment, they won't find it.
|
||||||
|
|
||||||
|
**Fix:** Update the comment block to say "promoted to full assertions (Wave 2 complete)" and remove references to placeholder bodies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-03: `admin_client` fixture clears `app.state.redis` after yield but `test_activate_user_does_not_write_user_nbf` accesses it via `app.state.redis` *before* teardown — FakeRedis instance identity is fragile
|
||||||
|
|
||||||
|
**File:** `backend/tests/test_admin_api.py:473-503`
|
||||||
|
|
||||||
|
**Issue:** The test at line 473 manually pokes `fake_redis._store.pop(...)` (line 492) using a direct dict access on the `_store` attribute of `FakeRedis`. This couples the test to the internal implementation detail of `FakeRedis`. If `FakeRedis._store` is renamed or the implementation changes, this test silently stops clearing the key — the `pop` on a missing key returns `None` without error, so the test would pass vacuously if the store key format changed. The test's correctness depends on `_store` being a `dict` with exact string keys matching `f"user_nbf:{target.id}"`, but the FakeRedis `set` method stores `(value, deadline)` tuples, and the store key is the raw string key. This is actually fine structurally, but the `.pop` bypass skips the TTL semantics.
|
||||||
|
|
||||||
|
**Fix:** Instead of poking `_store` directly, use the public `set` interface to overwrite and then delete via a new `delete` method, or simply restructure the test to activate a user that was never previously deactivated (avoiding the need to clear a prior NBF write entirely).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-04: `FakeRedis.get()` returns the stored value directly (not bytes) when non-bytes values are stored, but `test_deactivate_user_writes_user_nbf_to_redis` assumes `.decode()` is available
|
||||||
|
|
||||||
|
**File:** `backend/tests/test_admin_api.py:469`
|
||||||
|
|
||||||
|
**Issue:** The assertion at line 469 is:
|
||||||
|
```python
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
```
|
||||||
|
This is correct and tolerates both types. However, `admin.py` writes `int(time.time())` — a Python `int` — as the Redis value (line 359). `FakeRedis.set()` stores the value as-is (no bytes conversion), so `FakeRedis.get()` returns an `int`, not `bytes`. The `isinstance(nbf_bytes, (bytes, bytearray))` branch is False, the `else nbf_bytes` branch returns the raw `int`, and `int(int_value) > 0` evaluates correctly. This works by accident. In production, `aioredis` stores bytes and returns bytes, so `int(time.time())` would need `.to_bytes()` or `str(...)` encoding to be round-trippable. The production `redis.set(key, int(time.time()))` will serialize the integer as a string bytes representation (Redis accepts integers), and `redis.get()` returns `b"1749123456"`, so the decode path works. But the FakeRedis path silently diverges from production semantics.
|
||||||
|
|
||||||
|
**Fix:** In all write sites, store the value as `str(int(time.time()))` rather than bare `int(time.time())` to make FakeRedis and production Redis behavior identical. Example in admin.py line 359:
|
||||||
|
```python
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{user.id}",
|
||||||
|
str(int(time.time())), # str not int: consistent with aioredis bytes return
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Apply the same change to all four write sites in auth.py (lines 510, 609, 655).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Info
|
||||||
|
|
||||||
|
### IN-01: `test_admin_api.py` two-step "activate after deactivate" test accesses `main.app` directly rather than the client's transport app
|
||||||
|
|
||||||
|
**File:** `backend/tests/test_admin_api.py:479-503`
|
||||||
|
|
||||||
|
**Issue:** The test imports `from main import app` at line 479 and uses `app.state.redis` at line 491 and 502. The `admin_client` fixture also imports and configures `from main import app` and sets `app.state.redis = FakeRedis()`. Since these are the same module-level singleton, this works — but it is a fragile pattern. If the test infrastructure is ever refactored to use sub-apps or the fixture creates a different app instance, this test will silently access the wrong Redis. The companion test in `test_auth_deps.py` correctly uses `auth_client._transport.app.state.redis` (line 200) which follows the actual request path.
|
||||||
|
|
||||||
|
**Fix:** Use `client._transport.app.state.redis` consistently instead of importing `app` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IN-02: `services/auth.py` — `verify_backup_code` does not early-exit after finding a match; it continues iterating all remaining rows
|
||||||
|
|
||||||
|
**File:** `backend/services/auth.py:363-368`
|
||||||
|
|
||||||
|
**Issue:** The comment at line 364 says "Always call verify_password for ALL rows (constant-time: no early exit)" — this is intentional to prevent timing-based enumeration. This is architecturally correct for security, not a bug. However, the comment justification is incomplete: after `matched_row` is set, subsequent `verify_password` calls still iterate through remaining rows, but the `matched_row` is overwritten if a *second* code also matches (which should be impossible given unique hashed codes). The comment should explicitly state that `matched_row` is intentionally overwritten rather than accumulated, so a future developer doesn't "optimize" this into an early return.
|
||||||
|
|
||||||
|
**Fix:** Add a clarifying comment: `# matched_row intentionally overwritten if somehow two hashes collide — last match wins; does not affect security since we need only one valid code`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Reviewed: 2026-06-06_
|
||||||
|
_Reviewer: Claude (gsd-code-reviewer)_
|
||||||
|
_Depth: standard_
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
phase: 7.2
|
||||||
|
slug: security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-06-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 7.2 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | pytest + pytest-asyncio |
|
||||||
|
| **Config file** | `backend/pytest.ini` |
|
||||||
|
| **Quick run command** | `pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py -v` |
|
||||||
|
| **Full suite command** | `cd backend && pytest -v` |
|
||||||
|
| **Estimated runtime** | ~30 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py -v`
|
||||||
|
- **After every plan wave:** Run `cd backend && pytest -v`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** ~30 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| jti-claim | 01 | 0 | SEC (JTI) | — | Every access token contains a `jti` UUID key | unit | `pytest tests/test_task2_auth_service.py -k jti -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| test-fakeredis | 01 | 0 | SEC (infra) | — | `test_auth_deps.py` test app has `app.state.redis` set | unit | `pytest tests/test_auth_deps.py -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-write-change-password | 02 | 1 | SEC (NBF-write) | T-7.2-01 | `change_password` writes `user_nbf:{user_id}` to Redis | integration | `pytest tests/test_auth_api.py -k nbf -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-write-enable-totp | 02 | 1 | SEC (NBF-write) | T-7.2-01 | `enable_totp` writes `user_nbf:{user_id}` to Redis | integration | `pytest tests/test_auth_api.py -k nbf -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-write-disable-totp | 02 | 1 | SEC (NBF-write) | T-7.2-01 | `disable_totp` writes `user_nbf:{user_id}` to Redis | integration | `pytest tests/test_auth_api.py -k nbf -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-write-deactivation | 03 | 1 | SEC (NBF-write) | T-7.2-01 | admin deactivation writes `user_nbf:{user_id}` to Redis | integration | `pytest tests/test_admin_api.py -k nbf -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-check-reject | 04 | 1 | SEC (NBF-check) | T-7.2-02 | Token with `iat < user_nbf` returns HTTP 401 | integration | `pytest tests/test_auth_deps.py -k nbf -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-check-allow | 04 | 1 | SEC (NBF-check) | T-7.2-03 | Token with `iat > user_nbf` is accepted | integration | `pytest tests/test_auth_deps.py -k nbf_after -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| nbf-fail-open | 04 | 1 | SEC (fail-open) | T-7.2-04 | Redis error in NBF check does not block request | unit | `pytest tests/test_auth_deps.py -k failopen -v` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `backend/tests/test_task2_auth_service.py` — add JTI presence test for `create_access_token`
|
||||||
|
- [ ] `backend/tests/test_auth_deps.py` — add `app.state.redis = FakeRedis()` to `make_test_app()` or auth_client fixture; add NBF-check tests (iat < nbf → 401, iat > nbf → pass, Redis error → pass)
|
||||||
|
- [ ] `backend/tests/test_auth_api.py` — add NBF-write stub tests for `change_password`, `enable_totp`, `disable_totp`
|
||||||
|
- [ ] `backend/tests/test_admin_api.py` — add NBF-write stub test for deactivation handler
|
||||||
|
|
||||||
|
*Existing 34+ auth tests in `test_auth_deps.py` and `test_auth_api.py` must remain green with zero regressions.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Recovering session after password change | SEC (pitfall 3) | Requires live frontend + token refresh flow | 1. Login. 2. Change password via API. 3. Use old access token → expect 401. 4. Use refresh token → expect new access token with `iat > nbf`. 5. Confirm new token works. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
| Threat | STRIDE | Mitigation in this phase |
|
||||||
|
|--------|--------|--------------------------|
|
||||||
|
| T-7.2-01: Stolen access token replay after security event | EoP | `user_nbf:{user_id}` Redis key blocks all tokens with `iat` before the event |
|
||||||
|
| T-7.2-02: HTTPException swallowed by fail-open catch | EoP | Explicit `except HTTPException: raise` guard before broad `except Exception` in `get_current_user` |
|
||||||
|
| T-7.2-03: Wrong comparison direction in NBF check | EoP | `payload["iat"] < nbf_timestamp` — token issued BEFORE event → reject |
|
||||||
|
| T-7.2-04: Redis outage bypasses revocation check | DoS/EoP | Fail-open accepted (D-04); availability > revocation during outage — known trade-off |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 30s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||||||
|
verified: 2026-06-06T00:00:00Z
|
||||||
|
status: gaps_found
|
||||||
|
score: 8/9 must-haves verified
|
||||||
|
overrides_applied: 0
|
||||||
|
gaps:
|
||||||
|
- truth: "password_reset_confirm writes user_nbf to Redis — pre-reset access tokens are blocked within TTL"
|
||||||
|
status: failed
|
||||||
|
reason: "password_reset_confirm (api/auth.py:700-746) revokes all refresh tokens but never writes user_nbf:{user_id} to Redis. Pre-reset access tokens remain valid for up to 15 minutes after a successful password-reset-link confirm. This is the identical gap the rest of Phase 7.2 was designed to close (T-7.2-01), and the code review (CR-02 in 07.2-REVIEW.md) explicitly flagged it."
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/api/auth.py"
|
||||||
|
issue: "password_reset_confirm (line 700) calls revoke_all_refresh_tokens at line 742 and then session.commit() at line 743 with no user_nbf Redis write in between"
|
||||||
|
missing:
|
||||||
|
- "Add `await request.app.state.redis.set(f'user_nbf:{user.id}', int(time.time()), ex=settings.access_token_expire_minutes * 60)` before session.commit() in password_reset_confirm"
|
||||||
|
- "Add `request: Request` parameter to password_reset_confirm signature (currently absent — the handler has no Request parameter)"
|
||||||
|
- "Add a test parallel to test_change_password_writes_user_nbf_to_redis covering password_reset_confirm"
|
||||||
|
deferred: []
|
||||||
|
human_verification:
|
||||||
|
- test: "Recovering session after password reset via email link"
|
||||||
|
expected: "After POST /api/auth/password-reset/confirm with a valid token, any previously-issued access token returns HTTP 401 with detail 'Session invalidated' on the next authenticated request"
|
||||||
|
why_human: "password_reset_confirm does not currently write user_nbf so this cannot be verified as passing; it requires manual end-to-end test after the gap is fixed"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 07.2: JTI Claim + Redis NBF Revocation — Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** JTI claim added to access tokens + Redis-based user_nbf revocation check closes the 15-minute access-token window after refresh-token revocation events
|
||||||
|
**Verified:** 2026-06-06
|
||||||
|
**Status:** gaps_found
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Every access token issued by `create_access_token` contains a unique `jti` UUID claim | VERIFIED | `services/auth.py:98` — `"jti": str(uuid.uuid4())` present in payload dict after `exp`; `import uuid` already at module top (line 25) |
|
||||||
|
| 2 | `get_current_user` rejects any access token whose `iat` predates `user_nbf:{user_id}` in Redis with HTTP 401 | VERIFIED | `deps/auth.py:68-85` — full NBF check block present; reads `user_nbf:{payload['sub']}`; raises `HTTPException(401, "Session invalidated")` when `payload["iat"] < int(nbf_str)` |
|
||||||
|
| 3 | `get_current_user` accepts tokens whose `iat` post-dates user_nbf (intended recovery path) | VERIFIED | Same block at `deps/auth.py:75` — comparison is strict `<` not `<=`; tokens issued after the event pass through |
|
||||||
|
| 4 | A Redis outage during the NBF check does not block requests — fail-open with warning log | VERIFIED | `deps/auth.py:83-84` — broad `except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc)` with no re-raise |
|
||||||
|
| 5 | The HTTPException raised by the NBF check is NOT swallowed by the fail-open broad-catch (Pitfall 1 guard) | VERIFIED | `deps/auth.py:81-82` — `except HTTPException: raise` at line 81 precedes `except Exception` at line 83; ordering confirmed by grep line numbers |
|
||||||
|
| 6 | `change_password`, `enable_totp`, `disable_totp` each write `user_nbf:{user_id}` to Redis before `session.commit()` | VERIFIED | `api/auth.py:508-511` (change_password), `api/auth.py:607-611` (enable_totp via `redis_client`), `api/auth.py:653-657` (disable_totp); all use `settings.access_token_expire_minutes * 60` TTL |
|
||||||
|
| 7 | Admin deactivation writes `user_nbf:{user_id}` only when `is_active=False` | VERIFIED | `api/admin.py:353-361` — write is strictly inside `if not body.is_active:` block; activation path at line 366 has no write |
|
||||||
|
| 8 | All TTLs use `settings.access_token_expire_minutes * 60` (not hardcoded 900) | VERIFIED | All 4 write sites confirmed by grep: `api/auth.py:511,610,656` and `api/admin.py:360` — all use the derived expression |
|
||||||
|
| 9 | `password_reset_confirm` writes `user_nbf` to close pre-reset access-token window | FAILED | `api/auth.py:700-746` — endpoint revokes refresh tokens (line 742) and commits (line 743) with no `user_nbf` write; has no `Request` parameter; pre-reset access tokens remain valid for up to 15 minutes |
|
||||||
|
|
||||||
|
**Score:** 8/9 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `backend/services/auth.py` | `create_access_token` with `jti=str(uuid.uuid4())` | VERIFIED | Line 98: `"jti": str(uuid.uuid4()),` |
|
||||||
|
| `backend/deps/auth.py` | `user_nbf` Redis check with fail-open + HTTPException re-raise guard | VERIFIED | Lines 62-85: full NBF check block; `import logging` at line 23; `_logger` at line 34 |
|
||||||
|
| `backend/api/auth.py` | `user_nbf` write in `change_password`, `enable_totp`, `disable_totp` | VERIFIED | Lines 507-512, 606-611, 652-657 |
|
||||||
|
| `backend/api/admin.py` | `user_nbf` write in deactivation handler only | VERIFIED | Lines 356-361; `import time` at line 26; `from config import settings` at line 31 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `services/auth.py create_access_token` | PyJWT encode payload | `"jti": str(uuid.uuid4())` in payload dict | WIRED | Line 98; uuid imported at module level line 25 |
|
||||||
|
| `deps/auth.py get_current_user` | `request.app.state.redis` | `await redis_client.get(f"user_nbf:{payload['sub']}")` + int comparison | WIRED | Lines 69-75 |
|
||||||
|
| `api/auth.py change_password` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{current_user.id}", ...)` | WIRED | Line 508-512 |
|
||||||
|
| `api/auth.py enable_totp` | `request.app.state.redis` | `await redis_client.set(f"user_nbf:{current_user.id}", ...)` (redis_client in scope) | WIRED | Lines 607-611 |
|
||||||
|
| `api/auth.py disable_totp` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{current_user.id}", ...)` | WIRED | Lines 653-657 |
|
||||||
|
| `api/admin.py deactivation handler` | `request.app.state.redis` | `await request.app.state.redis.set(f"user_nbf:{user.id}", ...)` inside `if not body.is_active:` | WIRED | Lines 357-361 |
|
||||||
|
| `api/auth.py password_reset_confirm` | `request.app.state.redis` | user_nbf write | NOT WIRED | Handler has no `Request` parameter and no Redis write; refresh-token revocation at line 742 is present but NBF write is absent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| `backend/api/auth.py` | 700-746 | Missing `user_nbf` write after password reset confirm | BLOCKER | Pre-reset access tokens valid 15 min after successful reset; exactly the gap this phase closes for all other security events |
|
||||||
|
| `backend/services/auth.py` | 100, 110 | `algorithm="HS256"` (symmetric) instead of `ES256` (asymmetric) | WARNING | CLAUDE.md Security Protocol mandates ES256; this is tracked and addressed in Phase 7.3 |
|
||||||
|
|
||||||
|
Note on HS256 (CR-01 from code review): Phase 7.3 (`07.3-security-es256-algorithm-upgrade-inserted`) is already planned and has research/plan artifacts present in `.planning/phases/`. The HS256 finding is deferred to Phase 7.3. It is a WARNING here, not a BLOCKER for Phase 7.2's stated goal — but it is noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|----------|
|
||||||
|
| CONCERNS:JTI-CLAIM | 07.2-01, 07.2-02 | Every access token carries a `jti` UUID claim | SATISFIED | `services/auth.py:98` |
|
||||||
|
| CONCERNS:JTI-REVOKE-REDIS | 07.2-01, 07.2-02, 07.2-03 | `user_nbf` Redis check closes 15-min revocation window on security events | PARTIALLY SATISFIED | Check implemented (deps/auth.py); 3 of 4 auth write sites present (change_password, enable_totp, disable_totp); admin deactivation present; `password_reset_confirm` missing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Post-password-reset access-token invalidation
|
||||||
|
|
||||||
|
**Test:** After POST /api/auth/password-reset/confirm with a valid reset token, immediately use the previously-issued access token to call GET /api/auth/me.
|
||||||
|
**Expected:** HTTP 401 with `{"detail": "Session invalidated"}` — the pre-reset token should be blocked within the Redis TTL window.
|
||||||
|
**Why human:** The gap (missing `user_nbf` write in `password_reset_confirm`) means this currently fails; human verification needed after the fix is applied to confirm the full round-trip works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
One gap blocks the phase goal:
|
||||||
|
|
||||||
|
**`password_reset_confirm` missing `user_nbf` write.** The phase goal is to close the 15-minute access-token window after revocation events. All four handlers named in the plans (change_password, enable_totp, disable_totp, admin deactivation) correctly write `user_nbf`. However, `password_reset_confirm` — a fifth security event that changes a user's password — revokes refresh tokens but does not write `user_nbf`. A user whose email is compromised can receive the reset link, confirm it, and the victim's live access token continues to work for up to 15 minutes. The code review (CR-02 in 07.2-REVIEW.md) identified this gap explicitly. The fix is surgical: add `request: Request` to the function signature and one `await request.app.state.redis.set(...)` call before `session.commit()`, matching the pattern in all other handlers.
|
||||||
|
|
||||||
|
The gap is not a scope ambiguity: the phase goal is stated as closing the "15-minute access-token window after refresh-token revocation events," and a password reset is unambiguously such an event. The other four handlers demonstrate the project is aware of this pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-06-06_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
phase: 07.3-security-es256-algorithm-upgrade-inserted
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 0
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/tests/test_auth_es256.py
|
||||||
|
- backend/tests/test_task1_models_config.py
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- ES256-01
|
||||||
|
- ES256-02
|
||||||
|
- ES256-03
|
||||||
|
- ES256-04
|
||||||
|
- ES256-05
|
||||||
|
- RM-01
|
||||||
|
- RM-02
|
||||||
|
- RM-03
|
||||||
|
- CFG-01
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Every Phase 7.3 verifiable behavior has a failing pytest stub before any production code change"
|
||||||
|
- "Existing test_settings_has_jwt_config covers the new refresh_token_expire_hours setting"
|
||||||
|
- "Tests are red (xfail) when run, not silently skipped"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tests/test_auth_es256.py"
|
||||||
|
provides: "9 xfail stubs covering ES256-01..05 + RM-01..03"
|
||||||
|
contains: "test_access_token_uses_es256, test_hs256_token_rejected, test_reset_token_uses_es256, test_startup_rotation_revokes_tokens, test_startup_rotation_idempotent, test_default_ttl_16_hours, test_remember_me_ttl_30_days, test_remember_me_cookie_max_age, es256_keys fixture"
|
||||||
|
- path: "backend/tests/test_task1_models_config.py"
|
||||||
|
provides: "Asserts refresh_token_expire_hours == 16 and jwt key fields present (will xfail until Plan 02)"
|
||||||
|
contains: "refresh_token_expire_hours"
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tests/test_auth_es256.py"
|
||||||
|
to: "backend/services/auth.py + backend/main.py + backend/api/auth.py"
|
||||||
|
via: "Imports create_access_token / create_password_reset_token / FastAPI test client"
|
||||||
|
pattern: "from services.auth import create_access_token"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the Wave 0 Nyquist test scaffold for Phase 7.3 (ES256 + startup rotation + remember_me). Every requirement ID listed in this phase MUST have a failing-on-purpose test stub before any production code changes. This is the test infrastructure that subsequent plans promote from xfail to passing.
|
||||||
|
|
||||||
|
Purpose: Enforce TDD discipline; guarantee every locked decision (D-01..D-12) has an automated check.
|
||||||
|
Output: One new test file (`test_auth_es256.py`) with 9 xfail stubs + a reusable ES256 key fixture, plus an extension to the existing config test asserting `refresh_token_expire_hours == 16`.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
||||||
|
@backend/tests/test_task1_models_config.py
|
||||||
|
@backend/tests/test_task2_auth_service.py
|
||||||
|
@backend/tests/test_auth_api.py
|
||||||
|
@backend/tests/conftest.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing test fixtures and helpers the new file relies on. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From backend/tests/conftest.py:
|
||||||
|
- pytest_asyncio.fixture `async_client` — httpx.AsyncClient bound to the FastAPI app (used for integration tests against /api/auth/login)
|
||||||
|
- pytest_asyncio.fixture `db_session` — AsyncSession for direct row inspection (used to assert RefreshToken.expires_at after login)
|
||||||
|
- pytest_asyncio.fixture `auth_user` — returns dict with `email`, `password`, `id` for a real registered user
|
||||||
|
|
||||||
|
From backend/tests/test_auth_api.py (lines 47-97 area):
|
||||||
|
- class `FakeRedis` — minimal in-memory stub exposing `get(key)`, `set(key, value, ex=...)`, `delete(key)` used to bypass real Redis during integration tests
|
||||||
|
- helper `_register(client, email, password)` — registers a user via POST /api/auth/register
|
||||||
|
- helper `_login(client, email, password, **extra)` — POSTs /api/auth/login and returns the response
|
||||||
|
|
||||||
|
From backend/services/auth.py:
|
||||||
|
```
|
||||||
|
def create_access_token(user_id: str, role: str) -> str # currently HS256 — Plan 02 swaps to ES256
|
||||||
|
def create_password_reset_token(user_id: str) -> str # currently HS256 — Plan 02 swaps to ES256
|
||||||
|
def decode_access_token(token: str) -> dict # currently HS256 — Plan 02 swaps to ES256
|
||||||
|
async def create_refresh_token(session, user_id) -> str # Plan 03 adds remember_me param
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/config.py (current JWT block, lines 33-35):
|
||||||
|
```
|
||||||
|
access_token_expire_minutes: int = 15
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
# Plan 02 adds: refresh_token_expire_hours: int = 16, jwt_private_key: str = "", jwt_public_key: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
From cryptography library (already in requirements.txt):
|
||||||
|
- `ec.generate_private_key(ec.SECP256R1())` — generate P-256 private key
|
||||||
|
- `serialization.Encoding.PEM`, `serialization.PrivateFormat.PKCS8`, `serialization.PublicFormat.SubjectPublicKeyInfo` — PEM serialization formats
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create test_auth_es256.py with 9 xfail stubs + ES256 key fixture</name>
|
||||||
|
<files>backend/tests/test_auth_es256.py</files>
|
||||||
|
<read_first>
|
||||||
|
backend/tests/test_task2_auth_service.py
|
||||||
|
backend/tests/test_auth_api.py
|
||||||
|
backend/tests/conftest.py
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create a new pytest module at backend/tests/test_auth_es256.py. Module docstring: "TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, and remember_me session TTL — all stubs xfail strict=True until promoted." Module imports: pytest, pytest_asyncio, base64, json, uuid, hashlib, time, datetime (timezone, timedelta), and (lazily inside test bodies where applicable) services.auth and config.settings.
|
||||||
|
|
||||||
|
Define an `autouse=True` module-level fixture `es256_keys(monkeypatch)` that (a) generates a P-256 private key with `ec.generate_private_key(ec.SECP256R1())` from cryptography.hazmat.primitives.asymmetric, (b) serializes private key as PEM PKCS8 NoEncryption then base64.b64encode().decode(), (c) serializes public key as PEM SubjectPublicKeyInfo then base64.b64encode().decode(), (d) monkeypatches `config.settings.jwt_private_key` and `config.settings.jwt_public_key` to these base64 strings. This fixture exists in Wave 0 even though those settings fields do not exist yet — `monkeypatch.setattr` with `raising=False` so it does not error before Plan 02 lands.
|
||||||
|
|
||||||
|
Declare exactly these 9 stubs, each guarded with `@pytest.mark.xfail(strict=True, reason="<REQ-ID>: not yet implemented")`. Function bodies contain ONLY `pytest.xfail("not yet implemented")` (single-line body) — no test logic. The reason strings and function names MUST match the VALIDATION.md per-task table exactly:
|
||||||
|
|
||||||
|
1. `test_access_token_uses_es256()` — ES256-01 — sync, no fixtures beyond es256_keys
|
||||||
|
2. `test_hs256_token_rejected()` — ES256-02 — sync; will craft HS256 token with PyJWT and pass to decode_access_token
|
||||||
|
3. `test_reset_token_uses_es256()` — ES256-03 — sync
|
||||||
|
4. `test_startup_rotation_revokes_tokens(db_session)` — ES256-04 — `@pytest.mark.asyncio async def`; uses db_session
|
||||||
|
5. `test_startup_rotation_idempotent(db_session)` — ES256-05 — `@pytest.mark.asyncio async def`
|
||||||
|
6. `test_default_ttl_16_hours(async_client, db_session, auth_user)` — RM-01 — `@pytest.mark.asyncio async def`
|
||||||
|
7. `test_remember_me_ttl_30_days(async_client, db_session, auth_user)` — RM-02 — `@pytest.mark.asyncio async def`
|
||||||
|
8. `test_remember_me_cookie_max_age(async_client, auth_user)` — RM-03 — `@pytest.mark.asyncio async def`
|
||||||
|
9. `test_settings_has_jwt_keys()` — CFG-01 satellite for jwt_private_key/jwt_public_key presence in settings — sync
|
||||||
|
|
||||||
|
Each stub MUST be reachable by `pytest --collect-only`. Use `strict=True` on xfail so that any premature pass breaks CI and forces explicit promotion. Do NOT import services.auth or main at module top level (it would crash before Plan 02 adds settings fields) — perform imports lazily inside test bodies where needed (already redundant because bodies only call `pytest.xfail`).
|
||||||
|
|
||||||
|
Do not write any assertion code. Do not implement the test bodies — Plans 02 and 03 promote each stub by replacing the `xfail` body with real assertions in the same task that lands the corresponding production change.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_es256.py --collect-only -q 2>&1 | grep -E "test_(access_token_uses_es256|hs256_token_rejected|reset_token_uses_es256|startup_rotation_revokes_tokens|startup_rotation_idempotent|default_ttl_16_hours|remember_me_ttl_30_days|remember_me_cookie_max_age|settings_has_jwt_keys)" | wc -l | tr -d ' '</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- File backend/tests/test_auth_es256.py exists
|
||||||
|
- `grep -c "@pytest.mark.xfail(strict=True" backend/tests/test_auth_es256.py` returns 9
|
||||||
|
- `grep -c "def es256_keys" backend/tests/test_auth_es256.py` returns 1
|
||||||
|
- `grep -c "pytest.xfail" backend/tests/test_auth_es256.py` returns at least 9 (one per stub body)
|
||||||
|
- `pytest tests/test_auth_es256.py --collect-only -q` lists all 9 tests by name
|
||||||
|
- `pytest tests/test_auth_es256.py -v` exits 0 with all 9 tests reported as XFAIL (none XPASS, none ERROR)
|
||||||
|
- File contains zero `assert` statements outside of the xfail body convention (greps `grep -c "^ assert " backend/tests/test_auth_es256.py` returns 0)
|
||||||
|
- Module top-level imports do NOT include `from services.auth import` (lazy imports only inside test bodies to avoid load-time failure before Plan 02)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>9 xfail stubs collected, all reported XFAIL on pytest run, fixture defined, no assertions present.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Extend test_settings_has_jwt_config for refresh_token_expire_hours + JWT key fields</name>
|
||||||
|
<files>backend/tests/test_task1_models_config.py</files>
|
||||||
|
<read_first>
|
||||||
|
backend/tests/test_task1_models_config.py
|
||||||
|
backend/config.py
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Append assertions to the existing `test_settings_has_jwt_config` function in backend/tests/test_task1_models_config.py. Do NOT replace existing assertions — keep `assert hasattr(settings, "refresh_token_expire_days")` and `assert settings.refresh_token_expire_days == 30` intact (Pitfall 5 in RESEARCH.md).
|
||||||
|
|
||||||
|
Add the following new assertions inside the same test function (after the existing ones), each on its own line:
|
||||||
|
- `assert hasattr(settings, "refresh_token_expire_hours")` — CFG-01
|
||||||
|
- `assert settings.refresh_token_expire_hours == 16` — CFG-01 / D-09
|
||||||
|
- `assert hasattr(settings, "jwt_private_key")` — CFG-01 / D-01
|
||||||
|
- `assert hasattr(settings, "jwt_public_key")` — CFG-01 / D-01
|
||||||
|
|
||||||
|
These assertions will FAIL until Plan 02 adds the three new fields to backend/config.py — that is intentional (Nyquist). Do NOT wrap this test in xfail. The test must hard-fail today and hard-pass after Plan 02. Plan 02 promotes by simply adding the fields; the test then passes automatically without code changes here.
|
||||||
|
|
||||||
|
Do not change any other tests in this file. Do not add new imports beyond what is already present (`from config import settings`).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && grep -c "refresh_token_expire_hours == 16" tests/test_task1_models_config.py</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "assert settings.refresh_token_expire_hours == 16" backend/tests/test_task1_models_config.py` returns exactly one match
|
||||||
|
- `grep "assert hasattr(settings, \"jwt_private_key\")" backend/tests/test_task1_models_config.py` returns exactly one match
|
||||||
|
- `grep "assert hasattr(settings, \"jwt_public_key\")" backend/tests/test_task1_models_config.py` returns exactly one match
|
||||||
|
- `grep "assert settings.refresh_token_expire_days == 30" backend/tests/test_task1_models_config.py` still returns one match (existing assertion preserved)
|
||||||
|
- Running `cd backend && pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x -v` FAILS with AttributeError on `refresh_token_expire_hours` (or on `jwt_private_key`) — this is the expected red-on-purpose state before Plan 02
|
||||||
|
- No new top-level imports added to the file beyond what already exists
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Test extended to assert all three new config fields; test fails red until Plan 02 lands the fields.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| pytest runner → backend modules | Test code imports production modules; lazy imports prevent load-time crashes before Plan 02 |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-07.3-01-01 | Tampering | Wave 0 stubs flipping to xpass silently | mitigate | `strict=True` on every `@pytest.mark.xfail` forces CI failure on unexpected pass; promotion is explicit (Plan 02 / Plan 03 must edit the file) |
|
||||||
|
| T-07.3-01-02 | Spoofing | Test fixture leaking real JWT keys into other tests | mitigate | `es256_keys` fixture uses freshly-generated P-256 keypair per module via `monkeypatch` — auto-reverted after each test; never reads real env vars |
|
||||||
|
| T-07.3-01-03 | Denial of Service | Module-level import of services.auth crashing before Plan 02 | mitigate | All imports of `services.auth` and `config.settings.jwt_*` are lazy (inside test bodies); module collects cleanly even before Plan 02 |
|
||||||
|
| T-07.3-01-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; PyJWT and cryptography already pinned in requirements.txt (per RESEARCH.md §Package Legitimacy Audit — slopcheck not required) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pytest tests/test_auth_es256.py --collect-only -q` lists exactly 9 tests
|
||||||
|
- `pytest tests/test_auth_es256.py -v` reports 9 XFAILED, 0 XPASSED, 0 ERROR, 0 PASSED
|
||||||
|
- `pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x` FAILS (intentional red state) with `AttributeError` on a missing settings field
|
||||||
|
- Full suite `pytest -v` shows same total + 9 new XFAILED + 1 new FAILED (existing 373 passed becomes 372 passed + 1 failed + 9 xfailed) — confirmed in Plan 02 verify step
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- backend/tests/test_auth_es256.py exists with 9 xfail stubs, all strict=True
|
||||||
|
- es256_keys autouse fixture generates a fresh P-256 keypair and monkeypatches settings.jwt_private_key / jwt_public_key (uses raising=False)
|
||||||
|
- backend/tests/test_task1_models_config.py::test_settings_has_jwt_config asserts refresh_token_expire_hours == 16 and presence of jwt_private_key / jwt_public_key
|
||||||
|
- No production source files modified in this plan
|
||||||
|
- pytest collects all stubs cleanly; collection does not require Plan 02 fields to exist
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-SUMMARY.md` documenting:
|
||||||
|
- Files added/modified
|
||||||
|
- Test counts before/after (e.g., 373 passed → 372 passed + 1 failed + 9 xfailed)
|
||||||
|
- Confirmation that no production code was touched
|
||||||
|
- Promotion plan: which task in Plan 02 / Plan 03 will flip each xfail to a real assertion
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
---
|
||||||
|
phase: 07.3-security-es256-algorithm-upgrade-inserted
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on:
|
||||||
|
- 07.3-01
|
||||||
|
files_modified:
|
||||||
|
- backend/config.py
|
||||||
|
- backend/services/auth.py
|
||||||
|
- backend/main.py
|
||||||
|
- backend/tests/test_auth_es256.py
|
||||||
|
- docker-compose.yml
|
||||||
|
- README.md
|
||||||
|
- .env.example
|
||||||
|
- frontend/package.json
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- ES256-01
|
||||||
|
- ES256-02
|
||||||
|
- ES256-03
|
||||||
|
- ES256-04
|
||||||
|
- ES256-05
|
||||||
|
- CFG-01
|
||||||
|
|
||||||
|
user_setup:
|
||||||
|
- service: jwt-keys
|
||||||
|
why: "ES256 requires a P-256 keypair that operators MUST generate locally and place in .env before the backend can start"
|
||||||
|
env_vars:
|
||||||
|
- name: JWT_PRIVATE_KEY
|
||||||
|
source: "Run the Python one-liner documented in README.md JWT Key Generation section"
|
||||||
|
- name: JWT_PUBLIC_KEY
|
||||||
|
source: "Output of the same one-liner"
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Every JWT signed by the backend uses ES256 (verified by inspecting the alg header)"
|
||||||
|
- "An HS256 token presented to decode_access_token raises ValueError (401 at the API layer)"
|
||||||
|
- "settings.secret_key is no longer referenced by any function in services/auth.py for JWT operations"
|
||||||
|
- "On first boot after upgrade, all active refresh_tokens rows have revoked=true"
|
||||||
|
- "On second boot with no algorithm change, the bulk-revoke is skipped (idempotent)"
|
||||||
|
- "Operators can generate the keypair with a single Python command from README.md"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/config.py"
|
||||||
|
provides: "jwt_private_key, jwt_public_key, refresh_token_expire_hours settings"
|
||||||
|
contains: "refresh_token_expire_hours"
|
||||||
|
- path: "backend/services/auth.py"
|
||||||
|
provides: "ES256 signing + verification at all 4 token sites"
|
||||||
|
contains: "algorithm=\"ES256\""
|
||||||
|
- path: "backend/main.py"
|
||||||
|
provides: "_rotate_tokens_on_algorithm_change lifespan hook"
|
||||||
|
contains: "_rotate_tokens_on_algorithm_change"
|
||||||
|
- path: "docker-compose.yml"
|
||||||
|
provides: "JWT_PRIVATE_KEY + JWT_PUBLIC_KEY env injection for backend + celery-worker services"
|
||||||
|
contains: "JWT_PRIVATE_KEY"
|
||||||
|
- path: "README.md"
|
||||||
|
provides: "Operator key-generation snippet"
|
||||||
|
contains: "JWT_PRIVATE_KEY"
|
||||||
|
key_links:
|
||||||
|
- from: "backend/services/auth.py"
|
||||||
|
to: "backend/config.py"
|
||||||
|
via: "base64.b64decode(settings.jwt_private_key).decode() inline at each signing site"
|
||||||
|
pattern: "base64.b64decode\\(settings.jwt_(private|public)_key\\)"
|
||||||
|
- from: "backend/main.py lifespan"
|
||||||
|
to: "backend/db/models.py SystemSettings + RefreshToken"
|
||||||
|
via: "select(SystemSettings) where provider_id=='jwt_algorithm' then text bulk UPDATE"
|
||||||
|
pattern: "jwt_algorithm"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Land the core ES256 algorithm upgrade and the startup token rotation hook. After this plan:
|
||||||
|
|
||||||
|
1. backend/config.py exposes jwt_private_key, jwt_public_key, refresh_token_expire_hours (D-01, D-09).
|
||||||
|
2. All 4 JWT signing/verification sites in backend/services/auth.py use ES256 with the private/public PEM derived from base64-encoded env vars (D-02, D-03).
|
||||||
|
3. settings.secret_key is removed from every JWT call (D-03) — services/auth.py does not reference it for signing anywhere.
|
||||||
|
4. backend/main.py lifespan runs _rotate_tokens_on_algorithm_change after the AI seed block: reads the jwt_algorithm marker row in system_settings, bulk-revokes all active refresh tokens via raw SQL UPDATE when the algorithm differs from "ES256", and upserts the marker (D-04, D-05) — idempotent on repeat boots.
|
||||||
|
5. docker-compose.yml passes JWT_PRIVATE_KEY and JWT_PUBLIC_KEY to both backend and celery-worker services (D-07).
|
||||||
|
6. README.md contains the cryptography-based Python one-liner that prints two base64-encoded PEM lines ready to paste into .env (D-06).
|
||||||
|
7. .env.example documents the two new variables (no real values).
|
||||||
|
|
||||||
|
Tests promoted from Plan 01 to passing: ES256-01, ES256-02, ES256-03, ES256-04, ES256-05, CFG-01.
|
||||||
|
|
||||||
|
Purpose: Asymmetric JWT signing — a leaked public key cannot forge tokens. Closes the HS256 downgrade concern in CONCERNS.md.
|
||||||
|
Output: Code changes above + 6 promoted tests, all in one phase boundary commit.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md
|
||||||
|
@backend/config.py
|
||||||
|
@backend/services/auth.py
|
||||||
|
@backend/main.py
|
||||||
|
@backend/services/ai_config.py
|
||||||
|
@backend/db/models.py
|
||||||
|
@backend/tests/test_auth_es256.py
|
||||||
|
@docker-compose.yml
|
||||||
|
@README.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
Critical contracts the executor needs. Extracted from codebase.
|
||||||
|
|
||||||
|
From backend/services/auth.py (current — lines 86, 99, 102, 109, 120, 132, 135, 141, 154-172):
|
||||||
|
- def create_access_token(user_id: str, role: str) -> str — currently jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
- def decode_access_token(token: str) -> dict — currently jwt.decode(token, settings.secret_key, algorithms=["HS256"]); preserves typ == "access" check
|
||||||
|
- def create_password_reset_token(user_id: str) -> str — currently HS256
|
||||||
|
- def decode_password_reset_token(token: str) -> str — currently HS256; returns sub claim as str
|
||||||
|
- async def create_refresh_token(session, user_id) -> str — currently timedelta(days=settings.refresh_token_expire_days); signature UNCHANGED in this plan (Plan 03 adds remember_me)
|
||||||
|
- async def rotate_refresh_token(session, raw_token) -> str (line ~214) — internally calls create_refresh_token(session, row.user_id) with no remember_me; left untouched in this plan
|
||||||
|
- Phase 7.2 adds jti claim to access token payload — preserved verbatim by PyJWT through ES256 (Assumption A3)
|
||||||
|
|
||||||
|
From backend/config.py (current Settings class JWT block — lines 31-35):
|
||||||
|
- secret_key: str = "CHANGEME" — KEPT (Phase 7.4 fingerprinting may use it)
|
||||||
|
- access_token_expire_minutes: int = 15 — KEPT
|
||||||
|
- refresh_token_expire_days: int = 30 — KEPT (used by remember_me opt-in in Plan 03)
|
||||||
|
|
||||||
|
From backend/db/models.py (lines 340-376):
|
||||||
|
- class SystemSettings(Base) — columns: id (UUID), provider_id (str, unique, NOT NULL), api_key_enc (Text nullable), base_url (Text nullable), model_name (Text NOT NULL default=""), context_chars (Integer NOT NULL default=8000), is_active (Boolean NOT NULL default=False), created_at, updated_at
|
||||||
|
- class RefreshToken(Base) line 87 — column revoked: Mapped[bool] line 102; bulk UPDATE target
|
||||||
|
|
||||||
|
From backend/main.py (lifespan — lines 135-186):
|
||||||
|
- @asynccontextmanager async def lifespan(app) — existing structure: MinIO bucket init → Redis init → admin bootstrap → seed_system_settings_from_env (wrapped in try/except for missing table) → yield → shutdown
|
||||||
|
- Existing try/except wrapper pattern at lines 172-180 (copy this pattern for the ES256 block)
|
||||||
|
- Existing import (line 15): from sqlalchemy import text (already imported — also add select to the same import line if not present)
|
||||||
|
- from services.ai_config import seed_system_settings_from_env (line 25) — sibling import; new helper lives in main.py alongside lifespan
|
||||||
|
|
||||||
|
From backend/services/ai_config.py:seed_system_settings_from_env (lines 201-244):
|
||||||
|
- Reference upsert pattern: select(SystemSettings).where(SystemSettings.provider_id == provider_id) → result.scalar_one_or_none() → insert or update → caller commits
|
||||||
|
|
||||||
|
From docker-compose.yml:
|
||||||
|
- backend service injects SECRET_KEY=${SECRET_KEY} at line ~64
|
||||||
|
- celery-worker service injects same at line ~103
|
||||||
|
- Pattern: NAME=${NAME} — placeholders resolved from operator's .env
|
||||||
|
|
||||||
|
From cryptography library (already in requirements.txt):
|
||||||
|
- from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
- from cryptography.hazmat.primitives import serialization
|
||||||
|
- ec.generate_private_key(ec.SECP256R1())
|
||||||
|
- serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code</name>
|
||||||
|
<files>backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py</files>
|
||||||
|
<read_first>
|
||||||
|
backend/config.py
|
||||||
|
backend/services/auth.py
|
||||||
|
backend/tests/test_auth_es256.py
|
||||||
|
backend/tests/test_task1_models_config.py
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- test_settings_has_jwt_config (existing): asserts refresh_token_expire_hours == 16, jwt_private_key/jwt_public_key fields present → must pass after config change
|
||||||
|
- test_access_token_uses_es256 (promote from xfail): create_access_token('u1','user') → decode header → header['alg'] == 'ES256'
|
||||||
|
- test_hs256_token_rejected (promote): construct an HS256 JWT signed with any HMAC secret with payload {sub: u1, typ: access, exp: future}; calling decode_access_token(that_token) raises ValueError (PyJWT InvalidAlgorithmError mapped to ValueError per existing handler)
|
||||||
|
- test_reset_token_uses_es256 (promote): create_password_reset_token('u1') → decode header → header['alg'] == 'ES256'; decode_password_reset_token round-trips and returns 'u1'
|
||||||
|
- test_settings_has_jwt_keys (promote): assert hasattr settings for jwt_private_key, jwt_public_key, refresh_token_expire_hours; assert refresh_token_expire_hours == 16
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Step 1 — backend/config.py: Inside the existing Settings class, immediately after the line refresh_token_expire_days: int = 30, add THREE new fields (in this order, with these exact identifiers and defaults):
|
||||||
|
- refresh_token_expire_hours: int = 16 per D-09 — default short session; keep refresh_token_expire_days: int = 30 untouched
|
||||||
|
- jwt_private_key: str = "" per D-01 — base64-encoded PEM; required at runtime, defaulted to empty string so import does not crash if env var missing
|
||||||
|
- jwt_public_key: str = "" per D-01 — base64-encoded PEM; same default rationale
|
||||||
|
|
||||||
|
DO NOT add a method on Settings. DO NOT remove secret_key. Leave a one-line comment block above the new fields citing D-01 and D-09. The pydantic-settings auto-load picks up JWT_PRIVATE_KEY and JWT_PUBLIC_KEY env vars automatically by field name (uppercased) — no Field(alias=...) needed.
|
||||||
|
|
||||||
|
Step 2 — backend/services/auth.py: Add import base64 to the existing standard-library imports block (line 18-26 area; place it alphabetically among hashlib/hmac/secrets). Do NOT remove any existing import. Verify import jwt and from config import settings are still present.
|
||||||
|
|
||||||
|
Step 3 — backend/services/auth.py: Replace create_access_token (line 86 area) so that the existing payload construction is unchanged but the final line becomes:
|
||||||
|
- decode key inline: private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
- return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
Per D-02 / D-03. Do NOT cache private_pem as a module-level global (Anti-Pattern in RESEARCH.md). Phase 7.2's jti claim (already in payload) is preserved verbatim.
|
||||||
|
|
||||||
|
Step 4 — backend/services/auth.py: Replace decode_access_token (line 102 area) so that inside the existing try block:
|
||||||
|
- public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
- payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
Per D-02. Keep the existing except jwt.ExpiredSignatureError / except jwt.PyJWTError clauses and the typ != "access" ValueError check EXACTLY as-is. The algorithms=["ES256"] whitelist is what makes HS256 tokens raise InvalidAlgorithmError → caught by existing PyJWTError handler → ValueError → 401 at API layer (per existing dependency).
|
||||||
|
|
||||||
|
Step 5 — backend/services/auth.py: Replace create_password_reset_token (line 120 area) — same pattern as Step 3: decode jwt_private_key inline, sign with algorithm="ES256". Per D-02.
|
||||||
|
|
||||||
|
Step 6 — backend/services/auth.py: Replace decode_password_reset_token (line 135 area) — same pattern as Step 4: decode jwt_public_key inline, verify with algorithms=["ES256"]. Keep the existing typ != "password_reset" check and the return payload["sub"] line untouched.
|
||||||
|
|
||||||
|
Step 7 — backend/services/auth.py: Verify that no other function in this file references settings.secret_key for jwt.encode or jwt.decode purposes. There MUST be zero hits for settings.secret_key in this file after the change (grep gate in acceptance_criteria). Per D-03. If grep finds any non-JWT use of settings.secret_key in services/auth.py, surface it in the SUMMARY (none currently expected — verified during planning).
|
||||||
|
|
||||||
|
Step 8 — backend/tests/test_auth_es256.py: Promote five tests by REMOVING the @pytest.mark.xfail(strict=True, ...) decorator from each AND replacing the pytest.xfail body with real assertions:
|
||||||
|
|
||||||
|
test_access_token_uses_es256: lazy import from services.auth import create_access_token; call with ('u1', 'user'); split the returned token on '.'; base64.urlsafe_b64decode the first segment with appropriate '=' padding; json.loads; assert header['alg'] == 'ES256'.
|
||||||
|
|
||||||
|
test_hs256_token_rejected: lazy import jwt, from services.auth import decode_access_token. Construct an HS256 token: jwt.encode({"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())}, "any-hs256-secret", algorithm="HS256"). Call decode_access_token(token) inside with pytest.raises(ValueError):.
|
||||||
|
|
||||||
|
test_reset_token_uses_es256: lazy import from services.auth import create_password_reset_token, decode_password_reset_token; call with 'u1'; header alg assertion as above; assert decode_password_reset_token(token) == 'u1'.
|
||||||
|
|
||||||
|
test_settings_has_jwt_keys: lazy import from config import settings; assert hasattr for jwt_private_key, jwt_public_key, refresh_token_expire_hours; assert settings.refresh_token_expire_hours == 16.
|
||||||
|
|
||||||
|
Plan 01's test_settings_has_jwt_config extension in test_task1_models_config.py also flips green automatically once the three new config fields exist.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_es256.py::test_access_token_uses_es256 tests/test_auth_es256.py::test_hs256_token_rejected tests/test_auth_es256.py::test_reset_token_uses_es256 tests/test_auth_es256.py::test_settings_has_jwt_keys tests/test_task1_models_config.py::test_settings_has_jwt_config -x -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -v '^#' backend/services/auth.py | grep -c 'settings.secret_key' returns 0 (D-03 enforced — no JWT code references secret_key)
|
||||||
|
- grep -c 'algorithm="ES256"' backend/services/auth.py returns 2 (two encode sites: access + reset)
|
||||||
|
- grep -c 'algorithms=\["ES256"\]' backend/services/auth.py returns 2 (two decode sites: access + reset)
|
||||||
|
- grep -c '"HS256"' backend/services/auth.py returns 0 (no leftover HS256 string anywhere in the file)
|
||||||
|
- grep -c 'base64.b64decode(settings.jwt_' backend/services/auth.py returns 4 (one decode per signing site)
|
||||||
|
- grep -c 'import base64' backend/services/auth.py returns 1
|
||||||
|
- backend/config.py contains 'refresh_token_expire_hours: int = 16' exactly once
|
||||||
|
- backend/config.py contains 'jwt_private_key: str = ""' and 'jwt_public_key: str = ""' exactly once each
|
||||||
|
- pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_hs256_token_rejected -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_settings_has_jwt_keys -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x exits 0 (PASSED — confirms refresh_token_expire_hours == 16 and JWT key fields exist)
|
||||||
|
- The five promoted tests no longer carry @pytest.mark.xfail decorators (grep -B1 'def test_(access_token_uses_es256|hs256_token_rejected|reset_token_uses_es256|settings_has_jwt_keys)' backend/tests/test_auth_es256.py shows no xfail decorator above each def)
|
||||||
|
- Phase 7.2's jti claim still present in access tokens (verified implicitly by the full suite passing — existing Phase 7.2 jti tests still green)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Config exposes the three new fields; all four JWT sites use ES256; secret_key reference count in services/auth.py is 0; five ES256/CFG-01 tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests</name>
|
||||||
|
<files>backend/main.py, backend/tests/test_auth_es256.py</files>
|
||||||
|
<read_first>
|
||||||
|
backend/main.py
|
||||||
|
backend/services/ai_config.py
|
||||||
|
backend/db/models.py
|
||||||
|
backend/services/auth.py
|
||||||
|
backend/tests/test_auth_es256.py
|
||||||
|
backend/tests/conftest.py
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- test_startup_rotation_revokes_tokens (promote): seed two RefreshToken rows with revoked=False + ensure no jwt_algorithm row in system_settings → run _rotate_tokens_on_algorithm_change(db_session) → both tokens now have revoked=True; one new SystemSettings row exists with provider_id='jwt_algorithm', model_name='ES256', is_active=False, context_chars=0
|
||||||
|
- test_startup_rotation_idempotent (promote): seed a jwt_algorithm row with model_name='ES256'; insert one fresh RefreshToken with revoked=False; run _rotate_tokens_on_algorithm_change(db_session); the fresh token's revoked remains False (no bulk update fired); system_settings row count for provider_id='jwt_algorithm' is still 1
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Step 1 — backend/main.py: Update the existing sqlalchemy import line (currently from sqlalchemy import text at line 15) to also import select: from sqlalchemy import select, text. If select is already imported via another line, do not add a duplicate — check first.
|
||||||
|
|
||||||
|
Step 2 — backend/main.py: Add a module-level async helper function _rotate_tokens_on_algorithm_change(session) defined ABOVE the lifespan function (between the existing helpers around line 130 and the @asynccontextmanager decorator at line 135). The function must follow the seed_system_settings_from_env upsert pattern from PATTERNS.md exactly:
|
||||||
|
|
||||||
|
- Docstring: explains "Idempotent ES256 migration (Phase 7.3 D-04/D-05). On every boot, compares the jwt_algorithm marker row in system_settings against 'ES256'. If absent or different, bulk-revokes all active refresh tokens via raw SQL UPDATE and upserts the marker (with is_active=False so the AI provider loader never returns this row). No-op on second boot."
|
||||||
|
- Local imports inside the function body to avoid circular deps: from db.models import SystemSettings
|
||||||
|
- Query: stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") → result = await session.execute(stmt) → row = result.scalar_one_or_none()
|
||||||
|
- Early return: if row is not None and row.model_name == "ES256": return (idempotent)
|
||||||
|
- Bulk revoke (single raw SQL — never iterate in Python): await session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false"))
|
||||||
|
- Upsert: if row is None, session.add(SystemSettings(id=uuid.uuid4(), provider_id="jwt_algorithm", model_name="ES256", context_chars=0, is_active=False, api_key_enc=None, base_url=None)) per Pitfall 3 (model_name NOT NULL satisfied with "ES256"; context_chars NOT NULL satisfied with 0; is_active=False per Anti-Pattern in RESEARCH.md); else row.model_name = "ES256"
|
||||||
|
- Commit: await session.commit() inside the helper (PATTERNS.md Pattern 3 explicitly calls commit inside the helper for self-containment)
|
||||||
|
- Add import uuid at top of main.py if not already imported (check existing imports first)
|
||||||
|
- Log on bulk-revoke: logging.getLogger(__name__).info("ES256 startup rotation: bulk-revoked all active refresh tokens") BEFORE the upsert. Do NOT log on the no-op idempotent path (would spam every boot)
|
||||||
|
|
||||||
|
Step 3 — backend/main.py: Inside the existing lifespan function, AFTER the AI seed try/except block (line 180 area) and BEFORE the yield, add a new try/except block following the EXACT same pattern (Pitfall 6 in RESEARCH.md). The block does:
|
||||||
|
- try: async with AsyncSessionLocal() as session: await _rotate_tokens_on_algorithm_change(session)
|
||||||
|
- except Exception as _es256_exc: log warning "ES256 rotation check skipped (table may not exist yet): %s" with the exception
|
||||||
|
|
||||||
|
The try/except wrap is REQUIRED — fresh containers may not have the system_settings table yet (per Pitfall 6).
|
||||||
|
|
||||||
|
Step 4 — backend/tests/test_auth_es256.py: Promote the two startup-rotation tests by REMOVING the @pytest.mark.xfail decorators and replacing the bodies with real assertions:
|
||||||
|
|
||||||
|
For test_startup_rotation_revokes_tokens(db_session):
|
||||||
|
- Lazy imports: from main import _rotate_tokens_on_algorithm_change, from db.models import RefreshToken, SystemSettings, User, from sqlalchemy import select, import uuid, hashlib, secrets, from datetime import datetime, timezone, timedelta
|
||||||
|
- Set-up: create a User row (minimal valid User — copy fixture pattern from conftest.py auth_user creation), insert two RefreshToken rows with revoked=False, expires_at = now + 1 day, distinct token_hash values; await db_session.flush(). Do not create any system_settings row for jwt_algorithm.
|
||||||
|
- Act: await _rotate_tokens_on_algorithm_change(db_session)
|
||||||
|
- Assert: re-query both RefreshToken rows via select(RefreshToken).where(RefreshToken.id.in_([...])) → both have revoked == True. Query SystemSettings where provider_id == "jwt_algorithm" → exactly one row exists, model_name == "ES256", is_active == False, context_chars == 0.
|
||||||
|
|
||||||
|
For test_startup_rotation_idempotent(db_session):
|
||||||
|
- Set-up: insert a SystemSettings row with provider_id="jwt_algorithm", model_name="ES256", context_chars=0, is_active=False, id=uuid.uuid4(); create a User; insert one RefreshToken with revoked=False; await db_session.flush().
|
||||||
|
- Act: await _rotate_tokens_on_algorithm_change(db_session)
|
||||||
|
- Assert: the RefreshToken row's revoked is STILL False (bulk update did not fire); count of SystemSettings rows where provider_id='jwt_algorithm' is 1 (no duplicate upsert).
|
||||||
|
|
||||||
|
Note: tests use db_session fixture (existing conftest fixture). The helper does its own commit — if conftest.py rolls back the test transaction, assertions on rows still hold within the same session. If isolation conflicts occur, use a fresh AsyncSessionLocal() inside the test instead of altering the production helper.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens tests/test_auth_es256.py::test_startup_rotation_idempotent -x -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -c 'async def _rotate_tokens_on_algorithm_change' backend/main.py returns 1
|
||||||
|
- grep -c 'await _rotate_tokens_on_algorithm_change(session)' backend/main.py returns 1 (called from lifespan)
|
||||||
|
- grep -c 'UPDATE refresh_tokens SET revoked = true WHERE revoked = false' backend/main.py returns 1 (raw bulk SQL — no Python iteration)
|
||||||
|
- grep -c "provider_id == \"jwt_algorithm\"" backend/main.py returns 1
|
||||||
|
- grep -c 'is_active=False' backend/main.py returns at least 1 (the upsert — never True for the marker row per Anti-Pattern in RESEARCH.md)
|
||||||
|
- grep -c 'ES256 rotation check skipped' backend/main.py returns 1 (try/except wrap per Pitfall 6)
|
||||||
|
- pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x exits 0 (PASSED)
|
||||||
|
- The two promoted tests no longer carry @pytest.mark.xfail decorators
|
||||||
|
- pytest tests/test_auth_es256.py -v reports 6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03) — no XPASS, no ERROR, no FAILED
|
||||||
|
- Helper imports SystemSettings lazily inside the function body (no top-level cycle with services or db modules)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Lifespan hook bulk-revokes on algorithm change and is idempotent thereafter; both startup-rotation tests pass; only RM-01..03 stubs remain xfail.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump</name>
|
||||||
|
<files>docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json</files>
|
||||||
|
<read_first>
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
backend/main.py
|
||||||
|
frontend/package.json
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Step 1 — docker-compose.yml: For the backend service environment block (around line 60-65, where SECRET_KEY=${SECRET_KEY} lives), add two new lines immediately after SECRET_KEY=${SECRET_KEY}:
|
||||||
|
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
|
||||||
|
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
|
||||||
|
|
||||||
|
Repeat the same two additions for the celery-worker service environment block (around line 99-103, after the existing SECRET_KEY=${SECRET_KEY} there). Celery worker reaches services/auth.create_access_token / create_password_reset_token through email/reset paths and must have the keys available.
|
||||||
|
|
||||||
|
Do NOT add the keys to the MinIO or PostgreSQL service blocks. Do NOT put literal key values in docker-compose.yml (D-07: no placeholders in repo).
|
||||||
|
|
||||||
|
Step 2 — README.md: Locate the environment-variable documentation section (search for "env" or "Environment" headings). If the README has an env-var table similar to a SECRET_KEY row, add two new rows for JWT_PRIVATE_KEY and JWT_PUBLIC_KEY with column values: "base64-encoded PEM private/public key for ES256 JWT signing (required)". If no table exists, add a new subsection under the existing env section.
|
||||||
|
|
||||||
|
Then add a NEW subsection titled "### JWT Key Generation" (match existing README heading style) containing:
|
||||||
|
- One paragraph: Phase 7.3 uses ES256 (ECDSA P-256) and requires a key pair generated locally; the public key is safe to share, the private key never leaves the deployment.
|
||||||
|
- A fenced bash code block containing the EXACT Python one-liner from RESEARCH.md Pattern 5. The one-liner: uses python3 -c with imports for ec, serialization, and base64; generates the P-256 private key with ec.generate_private_key(ec.SECP256R1()); serializes private as PEM PKCS8 NoEncryption then base64.b64encode; serializes public as PEM SubjectPublicKeyInfo then base64.b64encode; prints two lines formatted as JWT_PRIVATE_KEY=<value> and JWT_PUBLIC_KEY=<value>.
|
||||||
|
- One follow-up line: "Paste the two output lines into your .env file at the project root."
|
||||||
|
- One warning callout: "Rotating these keys invalidates every active session — the startup rotation hook will bulk-revoke all refresh tokens on the next boot."
|
||||||
|
|
||||||
|
Step 3 — .env.example: Add two new lines (if the file exists; create it if it does not, copying the existing pattern):
|
||||||
|
- JWT_PRIVATE_KEY=
|
||||||
|
- JWT_PUBLIC_KEY=
|
||||||
|
Place them adjacent to the existing SECRET_KEY= line for discoverability. If .env.example does not exist in the repo, create it with at minimum these two new lines plus a comment header "# Generated by running the Python snippet in README.md JWT Key Generation section".
|
||||||
|
|
||||||
|
Step 4 — version bump per CLAUDE.md: Update backend/main.py FastAPI(...) constructor (line 191 area) from version="0.1.1" to version="0.1.2". Update frontend/package.json version field from 0.1.1 to 0.1.2. Per CLAUDE.md patch-bump rule because Phase 7.3 ships user-facing behavior (new algorithm upgrade and login changes via Plan 03).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c 'JWT_PRIVATE_KEY=' docker-compose.yml; grep -c 'JWT_PUBLIC_KEY=' docker-compose.yml; grep -c 'JWT_PRIVATE_KEY' README.md; grep -c 'ec.generate_private_key(ec.SECP256R1())' README.md; grep -c 'version="0.1.2"' backend/main.py; grep -c '"version": "0.1.2"' frontend/package.json</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -c 'JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}' docker-compose.yml returns 2 (backend service + celery-worker service)
|
||||||
|
- grep -c 'JWT_PUBLIC_KEY=\${JWT_PUBLIC_KEY}' docker-compose.yml returns 2 (backend service + celery-worker service)
|
||||||
|
- docker-compose.yml does NOT contain any literal base64 PEM value (grep -E "JWT_(PRIVATE|PUBLIC)_KEY=[A-Za-z0-9+/]{32,}" docker-compose.yml returns no matches)
|
||||||
|
- README.md contains at least one occurrence of "JWT_PRIVATE_KEY" and the text "ec.generate_private_key(ec.SECP256R1())"
|
||||||
|
- README.md contains the warning "Rotating these keys invalidates every active session"
|
||||||
|
- .env.example contains lines "JWT_PRIVATE_KEY=" and "JWT_PUBLIC_KEY=" (no values after the equals sign)
|
||||||
|
- grep -c 'version="0.1.2"' backend/main.py returns 1 (FastAPI constructor updated)
|
||||||
|
- grep -c '"version": "0.1.2"' frontend/package.json returns 1
|
||||||
|
- docker-compose config -q exits 0 (compose file syntax remains valid; run from project root)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Both backend and celery-worker services receive the two JWT env vars; README documents key generation; .env.example documents the variable names; backend + frontend version bumped to 0.1.2.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| operator host shell → backend container | JWT_PRIVATE_KEY and JWT_PUBLIC_KEY flow as env vars via Docker; private key never leaves the backend container |
|
||||||
|
| client browser → /api/auth/* | All access and reset tokens crossing this boundary are signed with the ES256 private key; clients only see the signed JWT |
|
||||||
|
| backend → PostgreSQL system_settings + refresh_tokens | Startup rotation reads system_settings.jwt_algorithm, performs raw SQL bulk UPDATE on refresh_tokens (parameter-free literal — no injection vector) |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-07.3-02-01 | Spoofing | Algorithm confusion (HS256 token accepted by ES256 decoder) | mitigate | jwt.decode(token, public_pem, algorithms=["ES256"]) — explicit whitelist raises InvalidAlgorithmError; verified by test_hs256_token_rejected |
|
||||||
|
| T-07.3-02-02 | Spoofing | None-algorithm attack (alg: none header) | mitigate | PyJWT requires algorithms list; "none" never appears in the whitelist; verified implicitly by the algorithms=["ES256"] gate |
|
||||||
|
| T-07.3-02-03 | Information Disclosure | Private key leaked via committed config | mitigate | docker-compose.yml uses ${JWT_PRIVATE_KEY} only — no literal values; .env stays in .gitignore; D-07 explicitly forbids placeholder values in repo |
|
||||||
|
| T-07.3-02-04 | Tampering | Public key swapped at runtime to enable forgery | accept | Operator-controlled env var; outside this phase's scope; key rotation ceremony is a deferred item in CONTEXT.md |
|
||||||
|
| T-07.3-02-05 | Elevation of Privilege | Stale HS256 access tokens remain valid post-deploy | mitigate | Bulk refresh-token revocation forces re-login on next refresh attempt; 15-minute access token TTL self-expires; documented disruption in README.md per Pitfall 2 |
|
||||||
|
| T-07.3-02-06 | Tampering | startup rotation upserts is_active=True row that AI loader picks up | mitigate | is_active=False enforced in code (Anti-Pattern in RESEARCH.md); load_provider_config filters by is_active IS TRUE — never returns the jwt_algorithm marker row |
|
||||||
|
| T-07.3-02-07 | Denial of Service | startup crashes when system_settings table missing on fresh container | mitigate | try/except wrapper around lifespan rotation block (Pitfall 6); logs warning and continues; next boot after migrations completes successfully |
|
||||||
|
| T-07.3-02-08 | Information Disclosure | Decoded PEM cached at module scope leaking via reload | mitigate | base64.b64decode inline at each call site (Anti-Pattern in RESEARCH.md); no module-level globals |
|
||||||
|
| T-07.3-02-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; PyJWT and cryptography already pinned in requirements.txt (RESEARCH.md Package Legitimacy Audit confirms slopcheck not required) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- pytest tests/test_auth_es256.py -v reports 6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03)
|
||||||
|
- pytest tests/test_task1_models_config.py -v all green
|
||||||
|
- Full suite pytest -v shows zero new failures vs Phase 7.2 baseline; 6 net new PASSED relative to Plan 01 baseline
|
||||||
|
- grep -v '^#' backend/services/auth.py | grep -c 'settings.secret_key' returns 0 (D-03 enforced)
|
||||||
|
- grep -c '"HS256"' backend/services/auth.py returns 0
|
||||||
|
- Docker compose config -q exits 0 (yaml still valid)
|
||||||
|
- bandit -r backend/ — zero HIGH severity findings
|
||||||
|
- Manual smoke: docker compose up after pasting freshly generated keys → backend starts → POST /api/auth/login returns 200 with new ES256 token; decoding header confirms alg=ES256
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- backend/config.py exposes refresh_token_expire_hours (=16), jwt_private_key, jwt_public_key
|
||||||
|
- backend/services/auth.py has zero references to settings.secret_key and zero "HS256" strings; four ES256 sites use base64-decoded PEM at call time
|
||||||
|
- backend/main.py defines and calls _rotate_tokens_on_algorithm_change inside lifespan with try/except wrapping
|
||||||
|
- docker-compose.yml passes JWT_PRIVATE_KEY and JWT_PUBLIC_KEY to backend and celery-worker
|
||||||
|
- README.md documents the Python one-liner key generation snippet
|
||||||
|
- .env.example lists JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=
|
||||||
|
- Version bumped to 0.1.2 in backend/main.py and frontend/package.json
|
||||||
|
- 6 tests promoted from xfail to passing (ES256-01..05 + CFG-01 via test_settings_has_jwt_keys + extended test_settings_has_jwt_config); 3 remain xfail (RM-01..03) for Plan 03
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md` documenting:
|
||||||
|
- Files modified with line ranges
|
||||||
|
- Promoted tests (before xfail → after PASSED) and net suite delta
|
||||||
|
- Confirmation of grep gates (zero secret_key/HS256 references in services/auth.py)
|
||||||
|
- Operator handoff: confirm README key-gen snippet runs and prints two base64 lines; .env updated; docker compose up succeeds with rotated tokens
|
||||||
|
- Remaining xfail count and what Plan 03 will promote
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
---
|
||||||
|
phase: 07.3-security-es256-algorithm-upgrade-inserted
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 07.3-02
|
||||||
|
files_modified:
|
||||||
|
- backend/services/auth.py
|
||||||
|
- backend/api/auth.py
|
||||||
|
- backend/tests/test_auth_es256.py
|
||||||
|
- frontend/src/api/client.js
|
||||||
|
- frontend/src/stores/auth.js
|
||||||
|
- frontend/src/views/auth/LoginView.vue
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- RM-01
|
||||||
|
- RM-02
|
||||||
|
- RM-03
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Login without remember_me issues a refresh token with TTL = 16 hours (DB expires_at within ~16h)"
|
||||||
|
- "Login with remember_me=True issues a refresh token with TTL = 30 days (DB expires_at within ~30d)"
|
||||||
|
- "Login with remember_me=True sets cookie Max-Age = 30 * 86400 = 2592000"
|
||||||
|
- "Login without remember_me sets cookie Max-Age = 16 * 3600 = 57600"
|
||||||
|
- "LoginView.vue shows a 'Stay signed in for 30 days' checkbox below the password field; unchecked by default"
|
||||||
|
- "Checkbox state flows: LoginView ref → authStore.login(options.rememberMe) → api.login body.remember_me → backend LoginRequest.remember_me → create_refresh_token(remember_me=...)"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/services/auth.py"
|
||||||
|
provides: "create_refresh_token gains remember_me param selecting between hours and days TTL"
|
||||||
|
contains: "remember_me"
|
||||||
|
- path: "backend/api/auth.py"
|
||||||
|
provides: "LoginRequest.remember_me, _set_refresh_cookie remember_me param, login handler threading"
|
||||||
|
contains: "remember_me"
|
||||||
|
- path: "frontend/src/views/auth/LoginView.vue"
|
||||||
|
provides: "Stay-signed-in checkbox and pass-through to authStore.login"
|
||||||
|
contains: "Stay signed in for 30 days"
|
||||||
|
- path: "frontend/src/stores/auth.js"
|
||||||
|
provides: "login() forwards options.rememberMe to api.login body.remember_me"
|
||||||
|
contains: "remember_me"
|
||||||
|
- path: "frontend/src/api/client.js"
|
||||||
|
provides: "api.login accepts and forwards remember_me field"
|
||||||
|
contains: "remember_me"
|
||||||
|
key_links:
|
||||||
|
- from: "frontend/src/views/auth/LoginView.vue"
|
||||||
|
to: "frontend/src/stores/auth.js"
|
||||||
|
via: "authStore.login(email, password, { rememberMe: rememberMe.value })"
|
||||||
|
pattern: "rememberMe: rememberMe.value"
|
||||||
|
- from: "frontend/src/stores/auth.js"
|
||||||
|
to: "backend/api/auth.py LoginRequest"
|
||||||
|
via: "api.login body.remember_me = options.rememberMe ?? false"
|
||||||
|
pattern: "remember_me: options.rememberMe"
|
||||||
|
- from: "backend/api/auth.py login handler"
|
||||||
|
to: "backend/services/auth.py create_refresh_token"
|
||||||
|
via: "create_refresh_token(session, user.id, remember_me=body.remember_me) + _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)"
|
||||||
|
pattern: "remember_me=body.remember_me"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Ship the "Stay signed in for 30 days" opt-in. After this plan:
|
||||||
|
|
||||||
|
1. backend/services/auth.py:create_refresh_token gains remember_me: bool = False; selects timedelta(hours=settings.refresh_token_expire_hours) by default and timedelta(days=settings.refresh_token_expire_days) when True. (D-09, D-10, D-11)
|
||||||
|
2. backend/api/auth.py LoginRequest gains remember_me: bool = False. (D-11)
|
||||||
|
3. backend/api/auth.py _set_refresh_cookie gains remember_me: bool = False and computes max_age accordingly. (D-11)
|
||||||
|
4. The login handler passes body.remember_me through to both create_refresh_token and _set_refresh_cookie. (D-11)
|
||||||
|
5. frontend/src/views/auth/LoginView.vue gains a "Stay signed in for 30 days" checkbox; its state flows through submitPassword/submitTotp/submitBackupCode into authStore.login(options.rememberMe). (D-12)
|
||||||
|
6. frontend/src/stores/auth.js login() forwards options.rememberMe as remember_me into the api.login body. (D-12)
|
||||||
|
7. frontend/src/api/client.js api.login accepts a body shape with remember_me. (D-12 — implicit; api.login is a thin wrapper today and simply forwards the body, so verify it does not drop the field.)
|
||||||
|
8. Refresh-token rotation (rotate_refresh_token in services/auth.py) is intentionally NOT updated — rotated sessions revert to the 16-hour default (per Pitfall 4 and Open Question 1 in RESEARCH.md; CONTEXT.md is silent → simpler path chosen).
|
||||||
|
|
||||||
|
Tests promoted from Plan 01 to passing: RM-01, RM-02, RM-03.
|
||||||
|
|
||||||
|
This plan ends the phase. After this plan completes and the human checkpoint passes, Phase 7.3 is shippable.
|
||||||
|
|
||||||
|
Purpose: Bound default session lifetime to a workday; let users explicitly opt into a 30-day session when convenient.
|
||||||
|
Output: Backend + frontend changes + 3 promoted tests + human verification of the checkbox and cookie Max-Age.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
||||||
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md
|
||||||
|
@backend/services/auth.py
|
||||||
|
@backend/api/auth.py
|
||||||
|
@backend/tests/test_auth_es256.py
|
||||||
|
@backend/tests/test_auth_api.py
|
||||||
|
@frontend/src/views/auth/LoginView.vue
|
||||||
|
@frontend/src/stores/auth.js
|
||||||
|
@frontend/src/api/client.js
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From backend/services/auth.py (post Plan 02):
|
||||||
|
- async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str — Plan 03 changes signature to add remember_me: bool = False as the final keyword arg
|
||||||
|
- async def rotate_refresh_token(session: AsyncSession, raw_token: str) -> str — internally calls create_refresh_token(session, row.user_id) with no kwargs; LEFT UNTOUCHED in this plan (rotated tokens default to 16h per Pitfall 4)
|
||||||
|
|
||||||
|
From backend/api/auth.py:
|
||||||
|
- class LoginRequest(BaseModel) (lines 56-60) — email, password, totp_code Optional, backup_code Optional; Plan 03 adds remember_me: bool = False
|
||||||
|
- def _set_refresh_cookie(response, raw_token) -> None (lines 70-80) — sets refresh_token cookie with httponly, secure, samesite=strict, path=/api/auth/refresh, max_age=settings.refresh_token_expire_days * 86400; Plan 03 changes to (response, raw_token, remember_me=False) and computes max_age conditionally
|
||||||
|
- Login handler call sites (lines 277-279):
|
||||||
|
- access_token = auth_service.create_access_token(str(user.id), user.role)
|
||||||
|
- raw_refresh = await auth_service.create_refresh_token(session, user.id)
|
||||||
|
- _set_refresh_cookie(response, raw_refresh)
|
||||||
|
- Refresh handler call site (line 349) — _set_refresh_cookie(response, new_raw); LEFT UNTOUCHED (rotation does not preserve remember_me)
|
||||||
|
|
||||||
|
From frontend/src/api/client.js:
|
||||||
|
- export function login(body) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) } — pass-through; verify it forwards the body without filtering remember_me out
|
||||||
|
|
||||||
|
From frontend/src/stores/auth.js (lines 60-88):
|
||||||
|
- async function login(email, password, options = {}) → currently passes email, password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null in the api.login body; Plan 03 adds remember_me: options.rememberMe ?? false to the same body
|
||||||
|
|
||||||
|
From frontend/src/views/auth/LoginView.vue:
|
||||||
|
- Existing form structure: step-based (password step → optional TOTP step → optional backup-code step)
|
||||||
|
- Existing refs (lines 188-193): email, password, totpInput, backupCodeInput, loading, error
|
||||||
|
- Existing handlers:
|
||||||
|
- submitPassword (lines 224-235): calls authStore.login(email.value, password.value)
|
||||||
|
- submitTotp (lines 237-250): calls authStore.login(email.value, password.value, { totpCode: totpInput.value })
|
||||||
|
- submitBackupCode (lines 252-265): calls authStore.login(email.value, password.value, { backupCode: backupCodeInput.value })
|
||||||
|
|
||||||
|
From backend/tests/test_auth_api.py:
|
||||||
|
- FakeRedis class (lines ~47-97 area) — in-memory Redis stub for integration tests
|
||||||
|
- _register and _login helpers — used to register and log in real users via httpx.AsyncClient
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Backend remember_me — create_refresh_token TTL param + LoginRequest + cookie max_age + handler threading + promote 3 tests</name>
|
||||||
|
<files>backend/services/auth.py, backend/api/auth.py, backend/tests/test_auth_es256.py</files>
|
||||||
|
<read_first>
|
||||||
|
backend/services/auth.py
|
||||||
|
backend/api/auth.py
|
||||||
|
backend/tests/test_auth_es256.py
|
||||||
|
backend/tests/test_auth_api.py
|
||||||
|
backend/tests/conftest.py
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- test_default_ttl_16_hours (promote): POST /api/auth/login with no remember_me field (or remember_me=False) → look up the new RefreshToken row → expires_at - now is in (15.5h, 16.5h) window (allowing test runtime jitter)
|
||||||
|
- test_remember_me_ttl_30_days (promote): POST /api/auth/login with remember_me=True → look up the new RefreshToken row → expires_at - now is in (29.5d, 30.5d) window
|
||||||
|
- test_remember_me_cookie_max_age (promote): POST /api/auth/login with remember_me=True → response Set-Cookie header for refresh_token contains Max-Age=2592000 (30 * 86400); default login contains Max-Age=57600 (16 * 3600)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Step 1 — backend/services/auth.py: Update create_refresh_token signature from `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str` to `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False) -> str`. Per D-11.
|
||||||
|
|
||||||
|
Inside the function body, replace the line currently computing the TTL/expires_at:
|
||||||
|
- currently: expires_at=now + timedelta(days=settings.refresh_token_expire_days)
|
||||||
|
- new: insert a local `ttl = timedelta(days=settings.refresh_token_expire_days) if remember_me else timedelta(hours=settings.refresh_token_expire_hours)` BEFORE constructing the RefreshToken row; use `expires_at=now + ttl` in the row constructor. Per D-09 / D-10.
|
||||||
|
|
||||||
|
Do NOT touch rotate_refresh_token (Pitfall 4 acceptance — rotated tokens revert to 16h default). The internal call `await create_refresh_token(session, row.user_id)` continues to omit remember_me → defaults to False → 16h TTL. This is the documented acceptable behavior.
|
||||||
|
|
||||||
|
Step 2 — backend/api/auth.py: In the LoginRequest BaseModel definition (lines 56-60 area), add one new field after backup_code: `remember_me: bool = False`. Per D-11. Default False so existing clients that omit the field receive the short-session behavior.
|
||||||
|
|
||||||
|
Step 3 — backend/api/auth.py: Update _set_refresh_cookie signature (line 70 area) from `def _set_refresh_cookie(response: Response, raw_token: str) -> None` to `def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None`. Inside the function body, replace `max_age=settings.refresh_token_expire_days * 86400` with a conditional:
|
||||||
|
- max_age = settings.refresh_token_expire_days * 86400 if remember_me else settings.refresh_token_expire_hours * 3600
|
||||||
|
Per D-11 / RM-03. All other cookie attributes (key, value, httponly, secure, samesite, path) remain UNCHANGED.
|
||||||
|
|
||||||
|
Step 4 — backend/api/auth.py: In the login handler (line 277-279 area), thread remember_me through both downstream calls:
|
||||||
|
- raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
|
||||||
|
- _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
|
||||||
|
Per D-11.
|
||||||
|
|
||||||
|
Step 5 — backend/api/auth.py: Do NOT change the refresh handler call site at line 349. _set_refresh_cookie(response, new_raw) continues to call with default remember_me=False → rotated sessions get the 16h short cookie. This is the documented acceptable trade-off (Pitfall 4; Open Question 1 in RESEARCH.md).
|
||||||
|
|
||||||
|
Step 6 — backend/tests/test_auth_es256.py: Promote the three RM tests by REMOVING the @pytest.mark.xfail decorators and replacing the bodies with real assertions:
|
||||||
|
|
||||||
|
For test_default_ttl_16_hours(async_client, db_session, auth_user):
|
||||||
|
- Lazy imports: from db.models import RefreshToken, from sqlalchemy import select, from datetime import datetime, timezone, timedelta
|
||||||
|
- Act: POST /api/auth/login via async_client with body {"email": auth_user["email"], "password": auth_user["password"]} (no remember_me) — handle TOTP/backup-code chain if auth_user is TOTP-enrolled by checking the existing test_auth_api login helpers
|
||||||
|
- Assert response status == 200; fetch the most-recent RefreshToken for auth_user.id via select(RefreshToken).where(RefreshToken.user_id == uid).order_by(RefreshToken.id.desc()) → row.expires_at - datetime.now(tz=timezone.utc) is in (timedelta(hours=15, minutes=30), timedelta(hours=16, minutes=30))
|
||||||
|
|
||||||
|
For test_remember_me_ttl_30_days(async_client, db_session, auth_user):
|
||||||
|
- Same flow; body includes "remember_me": True
|
||||||
|
- Assert row.expires_at - now is in (timedelta(days=29, hours=23), timedelta(days=30, hours=1))
|
||||||
|
|
||||||
|
For test_remember_me_cookie_max_age(async_client, auth_user):
|
||||||
|
- First call: POST /api/auth/login WITHOUT remember_me → response.headers.get_list("set-cookie") (httpx exposes this via headers.get_list or response.cookies) contains a refresh_token cookie with Max-Age=57600 (16*3600)
|
||||||
|
- Second call: POST /api/auth/login with remember_me=True → Set-Cookie Max-Age=2592000 (30*86400)
|
||||||
|
- If httpx ASGITransport response cookies normalize Max-Age, parse the raw "set-cookie" header text via response.headers.raw or response.headers.get_list("set-cookie") and assert the substring "Max-Age=57600" / "Max-Age=2592000" appears in the matching cookie line
|
||||||
|
|
||||||
|
Use the existing FakeRedis pattern from test_auth_api.py for any rate-limit/Redis dependencies. If auth_user fixture creates a TOTP-enrolled user, follow the same multi-step login the existing test_auth_api login flow uses (post password → use TOTP code via pyotp.TOTP(secret).now()) — verify by reading the existing test_auth_api login fixtures in the read_first list.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd backend && pytest tests/test_auth_es256.py::test_default_ttl_16_hours tests/test_auth_es256.py::test_remember_me_ttl_30_days tests/test_auth_es256.py::test_remember_me_cookie_max_age -x -v</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -c 'remember_me: bool = False' backend/services/auth.py returns at least 1 (create_refresh_token signature)
|
||||||
|
- grep -c 'timedelta(hours=settings.refresh_token_expire_hours)' backend/services/auth.py returns 1 (default TTL branch)
|
||||||
|
- grep -c 'timedelta(days=settings.refresh_token_expire_days)' backend/services/auth.py returns 1 (remember_me TTL branch)
|
||||||
|
- grep -c 'remember_me: bool = False' backend/api/auth.py returns at least 2 (LoginRequest field + _set_refresh_cookie param)
|
||||||
|
- grep -c 'remember_me=body.remember_me' backend/api/auth.py returns 2 (login handler passes through to both create_refresh_token and _set_refresh_cookie)
|
||||||
|
- grep -c 'refresh_token_expire_hours \* 3600' backend/api/auth.py returns 1 (default cookie max_age)
|
||||||
|
- grep -c 'refresh_token_expire_days \* 86400' backend/api/auth.py returns 1 (remember_me cookie max_age)
|
||||||
|
- rotate_refresh_token in backend/services/auth.py is UNCHANGED (no remember_me kwarg threading): grep -A20 'async def rotate_refresh_token' backend/services/auth.py | grep -c 'remember_me' returns 0
|
||||||
|
- Refresh handler call site at backend/api/auth.py line ~349 is UNCHANGED: the _set_refresh_cookie call there does NOT include a remember_me kwarg (verify by reading surrounding lines)
|
||||||
|
- pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x exits 0 (PASSED)
|
||||||
|
- pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x exits 0 (PASSED)
|
||||||
|
- The three promoted tests no longer carry @pytest.mark.xfail decorators
|
||||||
|
- pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED + 0 XPASS + 0 FAILED
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Backend honors remember_me end-to-end through create_refresh_token TTL and cookie Max-Age; all 9 phase tests pass; rotated sessions intentionally default to 16h.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Frontend remember_me — checkbox in LoginView, ref threading, store + api.login pass-through</name>
|
||||||
|
<files>frontend/src/views/auth/LoginView.vue, frontend/src/stores/auth.js, frontend/src/api/client.js</files>
|
||||||
|
<read_first>
|
||||||
|
frontend/src/views/auth/LoginView.vue
|
||||||
|
frontend/src/stores/auth.js
|
||||||
|
frontend/src/api/client.js
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||||
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Step 1 — frontend/src/api/client.js: Locate the existing export function login(body) (line ~154). Verify it forwards the body verbatim to request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) and does NOT filter or remap fields. If the current implementation already passes the full body object through, no change is needed. If it picks specific fields, extend the picked set to include remember_me. Document the file in the SUMMARY even if zero-line-change.
|
||||||
|
|
||||||
|
Step 2 — frontend/src/stores/auth.js: In the login(email, password, options = {}) action (lines 60-88), extend the body passed to api.login to include one new field on the SAME object literal alongside totp_code and backup_code:
|
||||||
|
- remember_me: options.rememberMe ?? false
|
||||||
|
Place it after backup_code for symmetry with backend LoginRequest field order. Per D-12. Do NOT change the function signature — options is already optional and additive.
|
||||||
|
|
||||||
|
Step 3 — frontend/src/views/auth/LoginView.vue: Add a new reactive ref alongside the existing form refs (lines 188-193 area): const rememberMe = ref(false). Per D-12.
|
||||||
|
|
||||||
|
Step 4 — frontend/src/views/auth/LoginView.vue: Add a checkbox markup inside the password step form. Insert between the password input block and the error/submit elements. Match existing Tailwind class conventions found elsewhere in this file. Use these exact identifiers:
|
||||||
|
- input element: v-model="rememberMe", id="remember-me", type="checkbox", class follows existing input styling (Tailwind: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" or equivalent matching adjacent inputs)
|
||||||
|
- label element: for="remember-me", class="text-sm text-gray-600" or whatever the existing label scheme is, text content: "Stay signed in for 30 days" (per CONTEXT.md Specifics, clearer than "Remember me")
|
||||||
|
- Wrap input + label in a containing div with flex layout: class="flex items-center gap-2"
|
||||||
|
|
||||||
|
Step 5 — frontend/src/views/auth/LoginView.vue: Update the three submit handlers to pass rememberMe through the options object:
|
||||||
|
- submitPassword (line 228 area): change authStore.login(email.value, password.value) → authStore.login(email.value, password.value, { rememberMe: rememberMe.value })
|
||||||
|
- submitTotp (line 241 area): change authStore.login(email.value, password.value, { totpCode: totpInput.value }) → authStore.login(email.value, password.value, { totpCode: totpInput.value, rememberMe: rememberMe.value })
|
||||||
|
- submitBackupCode (line 256 area): change authStore.login(email.value, password.value, { backupCode: backupCodeInput.value }) → authStore.login(email.value, password.value, { backupCode: backupCodeInput.value, rememberMe: rememberMe.value })
|
||||||
|
|
||||||
|
The single ref persists across TOTP/backup-code steps because it lives at component scope; users who tick the checkbox before submitting password do not need to re-tick on the second step.
|
||||||
|
|
||||||
|
Do NOT add the checkbox to the TOTP or backup-code step forms — D-12 specifies the password step only. The state from the first step is carried forward by the ref.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c 'remember_me: options.rememberMe' frontend/src/stores/auth.js; grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue; grep -c "v-model=\"rememberMe\"" frontend/src/views/auth/LoginView.vue; grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -c 'remember_me: options.rememberMe ?? false' frontend/src/stores/auth.js returns 1
|
||||||
|
- grep -c 'const rememberMe = ref(false)' frontend/src/views/auth/LoginView.vue returns 1
|
||||||
|
- grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue returns 1
|
||||||
|
- grep -c 'v-model="rememberMe"' frontend/src/views/auth/LoginView.vue returns 1
|
||||||
|
- grep -c 'id="remember-me"' frontend/src/views/auth/LoginView.vue returns at least 1 (input + label for)
|
||||||
|
- grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue returns 3 (submitPassword, submitTotp, submitBackupCode)
|
||||||
|
- frontend/src/api/client.js login(body) function still exists and forwards body fields without filtering (manual verification recorded in SUMMARY): grep -c 'export function login' frontend/src/api/client.js returns 1
|
||||||
|
- cd frontend && npm run build exits 0 (Vite build succeeds)
|
||||||
|
- cd frontend && npm test 2>/dev/null || true — pre-existing test suite still passes (no new test required for plain markup; behavior verified by backend integration tests in Task 1)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Login form shows the checkbox; ref threads through all three submit paths; store forwards remember_me to backend; build succeeds.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: Human checkpoint — verify "Stay signed in" UX and cookie Max-Age in browser</name>
|
||||||
|
<what-built>
|
||||||
|
- Backend ES256 JWT signing (Plan 02) + remember_me TTL split (Task 1) + frontend checkbox (Task 2)
|
||||||
|
- "Stay signed in for 30 days" checkbox on LoginView under the password field, unchecked by default
|
||||||
|
- Login without checkbox issues a refresh cookie with Max-Age ≈ 57600 (16h)
|
||||||
|
- Login with checkbox issues a refresh cookie with Max-Age = 2592000 (30d)
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Start the stack: docker compose up (ensure .env contains freshly generated JWT_PRIVATE_KEY and JWT_PUBLIC_KEY from the README snippet)
|
||||||
|
2. Open http://localhost:5173/login in a private/incognito window
|
||||||
|
3. CONFIRM: the password step shows the "Stay signed in for 30 days" checkbox below the password input; the checkbox is unchecked by default; the label text matches exactly
|
||||||
|
4. Open DevTools → Network → preserve log
|
||||||
|
5. Log in with valid credentials WITHOUT ticking the checkbox; complete TOTP / backup code if prompted
|
||||||
|
6. Open DevTools → Application → Cookies → http://localhost:5173 → find the refresh_token cookie; CONFIRM Max-Age is in the range 57000-58000 (≈ 16h, allowing for round-trip timing)
|
||||||
|
7. Log out (via the existing logout control); clear cookies
|
||||||
|
8. Log in again, this time TICKING the checkbox; CONFIRM the refresh_token cookie Max-Age is 2592000 (30 days)
|
||||||
|
9. While still logged in, in DevTools → Network tab, find the POST /api/auth/login response → CONFIRM Set-Cookie header contains "Max-Age=2592000" and the request body JSON contains "remember_me": true
|
||||||
|
10. CONFIRM no console errors in the browser DevTools console during the flow
|
||||||
|
11. CONFIRM that the existing TOTP / backup-code flow still works after checking the box (the ref persists across steps; the second-step submit also includes remember_me)
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues observed (checkbox missing, wrong Max-Age, console errors, broken TOTP flow, etc.)</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client form → /api/auth/login | remember_me is a typed Pydantic bool (defaults False) — non-boolean values rejected by FastAPI validation |
|
||||||
|
| backend → browser cookie store | refresh_token cookie remains httpOnly, Secure, SameSite=Strict regardless of remember_me — only TTL changes |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-07.3-03-01 | Elevation of Privilege | Long-lived remember_me session stolen via cookie theft | mitigate | Cookie remains httpOnly Secure SameSite=Strict; refresh-token family revocation (RFC 9700) on reuse still applies; 30-day TTL is bounded, not infinite |
|
||||||
|
| T-07.3-03-02 | Spoofing | Client tampers with remember_me to forge a 30-day session despite not opting in | accept | remember_me is operator-visible flag from the same user; no privilege escalation occurs (user always gets their own session); attacker only extending their own session length |
|
||||||
|
| T-07.3-03-03 | Information Disclosure | Cookie Max-Age leaks the user's "remember me" preference to network observers | accept | TLS protects the cookie in flight; Max-Age is observable only via local DevTools (same trust boundary as the cookie itself) |
|
||||||
|
| T-07.3-03-04 | Tampering | Frontend ref leaked across components, default value flips to true | mitigate | rememberMe ref is scoped to LoginView.vue setup block; default `ref(false)` enforced by code; checkbox unchecked-by-default confirmed in human checkpoint |
|
||||||
|
| T-07.3-03-05 | Denial of Service | Refresh handler downgrade — remember_me=True session silently shortened to 16h after rotation | accept | Documented in CONTEXT.md / RESEARCH.md Open Question 1 / Pitfall 4; behavior is conservative (shorter is more secure); future phase can preserve TTL by adding remember_me column to RefreshToken |
|
||||||
|
| T-07.3-03-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; existing PyJWT and cryptography pins unchanged |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED (all phase tests green)
|
||||||
|
- Full suite pytest -v: zero new failures vs Plan 02 baseline; 3 net new PASSED
|
||||||
|
- bandit -r backend/ — zero HIGH severity findings
|
||||||
|
- npm audit --audit-level=high — zero high/critical (existing baseline preserved)
|
||||||
|
- Human checkpoint approved: checkbox visible, default unchecked, two distinct Max-Age values observed in DevTools, TOTP flow still works
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- create_refresh_token in backend/services/auth.py accepts remember_me=False keyword and selects between hours and days TTL
|
||||||
|
- LoginRequest in backend/api/auth.py exposes remember_me: bool = False
|
||||||
|
- _set_refresh_cookie in backend/api/auth.py honors remember_me and sets max_age accordingly
|
||||||
|
- rotate_refresh_token and the refresh handler are intentionally NOT updated — rotated sessions revert to 16h default
|
||||||
|
- LoginView.vue shows the "Stay signed in for 30 days" checkbox; ref threads through all three submit handlers
|
||||||
|
- stores/auth.js and api/client.js forward remember_me to the backend
|
||||||
|
- All 9 tests in test_auth_es256.py pass (ES256-01..05, RM-01..03, CFG-01 satellite)
|
||||||
|
- Human checkpoint approved with documented Max-Age observations
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-SUMMARY.md` documenting:
|
||||||
|
- Files modified with line ranges
|
||||||
|
- Promoted tests (RM-01, RM-02, RM-03) and final phase test totals (9/9 phase tests green; full suite delta)
|
||||||
|
- Human-checkpoint observations (default Max-Age, remember_me Max-Age, console clean, TOTP flow OK)
|
||||||
|
- Note on rotated-session TTL behavior (Pitfall 4 documented, deferred for future preservation work)
|
||||||
|
- Phase 7.3 ready for /gsd:verify-work
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
# Phase 07.3: Security — ES256 Algorithm Upgrade - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-06-05
|
||||||
|
**Files analyzed:** 7 new/modified files
|
||||||
|
**Analogs found:** 7 / 7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|-------------------|------|-----------|----------------|---------------|
|
||||||
|
| `backend/services/auth.py` | service | request-response | self (4 signing sites + TTL param) | exact — modify in-place |
|
||||||
|
| `backend/config.py` | config | — | self (add 3 fields) | exact — modify in-place |
|
||||||
|
| `backend/main.py` | config/startup | event-driven | `backend/services/ai_config.py` (`seed_system_settings_from_env`) | exact — same startup-hook + upsert pattern |
|
||||||
|
| `backend/api/auth.py` | controller | request-response | self (`LoginRequest` + `_set_refresh_cookie`) | exact — modify in-place |
|
||||||
|
| `frontend/src/views/auth/LoginView.vue` | component | request-response | self (step-based login form) | exact — modify in-place |
|
||||||
|
| `frontend/src/stores/auth.js` | store | request-response | self (`login()` action) | exact — modify in-place |
|
||||||
|
| `backend/tests/test_auth_es256.py` | test | — | `backend/tests/test_task2_auth_service.py` + `backend/tests/test_auth_api.py` | role-match — same unit + integration pattern |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `backend/services/auth.py` — ES256 signing sites + TTL param
|
||||||
|
|
||||||
|
**Analog:** self — 4 sites to change in-place
|
||||||
|
|
||||||
|
**Current imports block** (lines 18–38):
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
import pyotp
|
||||||
|
from pwdlib import PasswordHash
|
||||||
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from db.models import BackupCode, Quota, RefreshToken, User
|
||||||
|
```
|
||||||
|
Add `import base64` to this block (no new packages).
|
||||||
|
|
||||||
|
**Site 1 — `create_access_token`** (lines 86–99, current HS256):
|
||||||
|
```python
|
||||||
|
def create_access_token(user_id: str, role: str) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"typ": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
```
|
||||||
|
Change last line to:
|
||||||
|
```python
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 2 — `decode_access_token`** (lines 102–117, current HS256):
|
||||||
|
```python
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise ValueError("Token has expired") from exc
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise ValueError(f"Invalid token: {exc}") from exc
|
||||||
|
|
||||||
|
if payload.get("typ") != "access":
|
||||||
|
raise ValueError("Token type mismatch: expected 'access'")
|
||||||
|
return payload
|
||||||
|
```
|
||||||
|
Change the `jwt.decode` call to:
|
||||||
|
```python
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 3 — `create_password_reset_token`** (lines 120–132):
|
||||||
|
```python
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```python
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 4 — `decode_password_reset_token`** (lines 135–149):
|
||||||
|
```python
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```python
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**`create_refresh_token` TTL param** (lines 154–172 — current signature):
|
||||||
|
```python
|
||||||
|
async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str:
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
row = RefreshToken(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=now + timedelta(days=settings.refresh_token_expire_days),
|
||||||
|
revoked=False,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return raw
|
||||||
|
```
|
||||||
|
Add `remember_me: bool = False` param; change TTL line to:
|
||||||
|
```python
|
||||||
|
ttl = (
|
||||||
|
timedelta(days=settings.refresh_token_expire_days)
|
||||||
|
if remember_me
|
||||||
|
else timedelta(hours=settings.refresh_token_expire_hours)
|
||||||
|
)
|
||||||
|
...
|
||||||
|
expires_at=now + ttl,
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `backend/config.py` — add 3 new settings
|
||||||
|
|
||||||
|
**Analog:** self — add after `refresh_token_expire_days` at line 35
|
||||||
|
|
||||||
|
**Current JWT block** (lines 33–35):
|
||||||
|
```python
|
||||||
|
# Auth / JWT (Phase 2)
|
||||||
|
access_token_expire_minutes: int = 15
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
```
|
||||||
|
Extend to:
|
||||||
|
```python
|
||||||
|
# Auth / JWT (Phase 2)
|
||||||
|
access_token_expire_minutes: int = 15
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
refresh_token_expire_hours: int = 16 # Phase 7.3: default short session
|
||||||
|
|
||||||
|
# JWT key pair (Phase 7.3 — ES256; both required in production)
|
||||||
|
# Values are base64-encoded PEM strings (single-line, shell-safe).
|
||||||
|
jwt_private_key: str = "" # JWT_PRIVATE_KEY env var — required
|
||||||
|
jwt_public_key: str = "" # JWT_PUBLIC_KEY env var — required
|
||||||
|
```
|
||||||
|
No methods on Settings. Callers decode inline with `base64.b64decode(settings.jwt_private_key).decode()` — consistent with how other base64 secrets are handled in this codebase (e.g., HKDF in `ai_config.py` decodes `cloud_creds_key` inline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `backend/main.py` — lifespan ES256 rotation check
|
||||||
|
|
||||||
|
**Primary analog:** `backend/services/ai_config.py:seed_system_settings_from_env` (lines 201–244) — exact pattern for reading and writing a `SystemSettings` key-value row at startup.
|
||||||
|
|
||||||
|
**Secondary analog:** existing lifespan try/except block (lines 172–180) — exact wrapping pattern for skipping when the table doesn't exist yet.
|
||||||
|
|
||||||
|
**Existing startup try/except block to copy** (lines 172–180):
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await seed_system_settings_from_env(session)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as _seed_exc:
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger(__name__).warning(
|
||||||
|
"AI provider seed skipped (table may not exist yet): %s", _seed_exc
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern to follow for the new rotation block** — slot in after the AI seed block (after line 180, before `yield`):
|
||||||
|
```python
|
||||||
|
# ES256 startup rotation (Phase 7.3 — D-04):
|
||||||
|
# If jwt_algorithm in system_settings differs from "ES256" (or is absent),
|
||||||
|
# bulk-revoke all refresh tokens and record the new algorithm.
|
||||||
|
# Wrapped in try/except so a missing table before migrations doesn't crash.
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await _rotate_tokens_on_algorithm_change(session)
|
||||||
|
except Exception as _es256_exc:
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger(__name__).warning(
|
||||||
|
"ES256 rotation check skipped (table may not exist yet): %s", _es256_exc
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_rotate_tokens_on_algorithm_change` helper** — define as a module-level async function above `lifespan`, using the `seed_system_settings_from_env` upsert pattern as the direct template:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# seed_system_settings_from_env upsert pattern (ai_config.py lines 219–244):
|
||||||
|
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
row = SystemSettings(
|
||||||
|
provider_id=provider_id,
|
||||||
|
model_name=model_name,
|
||||||
|
context_chars=context_chars,
|
||||||
|
is_active=True,
|
||||||
|
api_key_enc=None,
|
||||||
|
base_url=None,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
```
|
||||||
|
|
||||||
|
For the rotation helper, use `provider_id="jwt_algorithm"`, `model_name="ES256"`, `context_chars=0`, `is_active=False` (D-03 anti-pattern: never `is_active=True` for metadata rows). Add `text()` bulk UPDATE before the upsert when the algorithm differs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import text, select
|
||||||
|
from db.models import SystemSettings, RefreshToken
|
||||||
|
|
||||||
|
async def _rotate_tokens_on_algorithm_change(session) -> None:
|
||||||
|
"""Idempotent ES256 migration. Bulk-revokes tokens if jwt_algorithm changed."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row is not None and row.model_name == "ES256":
|
||||||
|
return # Already migrated
|
||||||
|
# Bulk-revoke all active refresh tokens (single SQL statement, no Python iteration)
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
import uuid as _uuid
|
||||||
|
session.add(SystemSettings(
|
||||||
|
id=_uuid.uuid4(),
|
||||||
|
provider_id="jwt_algorithm",
|
||||||
|
model_name="ES256",
|
||||||
|
context_chars=0,
|
||||||
|
is_active=False,
|
||||||
|
api_key_enc=None,
|
||||||
|
base_url=None,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
row.model_name = "ES256"
|
||||||
|
await session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required import addition at top of `main.py`** (alongside existing `from sqlalchemy import text`):
|
||||||
|
```python
|
||||||
|
from sqlalchemy import text, select # text already imported; add select
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `backend/api/auth.py` — LoginRequest + `_set_refresh_cookie`
|
||||||
|
|
||||||
|
**Analog:** self — modify `LoginRequest` at lines 56–60 and `_set_refresh_cookie` at lines 70–80; thread `remember_me` through the login handler at line 278–279.
|
||||||
|
|
||||||
|
**Current `LoginRequest`** (lines 56–60):
|
||||||
|
```python
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
totp_code: Optional[str] = None
|
||||||
|
backup_code: Optional[str] = None
|
||||||
|
```
|
||||||
|
Add one field:
|
||||||
|
```python
|
||||||
|
remember_me: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current `_set_refresh_cookie`** (lines 70–80):
|
||||||
|
```python
|
||||||
|
def _set_refresh_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint)."""
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=raw_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
max_age=settings.refresh_token_expire_days * 86400,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Add `remember_me: bool = False` param; change `max_age` to:
|
||||||
|
```python
|
||||||
|
max_age=(
|
||||||
|
settings.refresh_token_expire_days * 86400
|
||||||
|
if remember_me
|
||||||
|
else settings.refresh_token_expire_hours * 3600
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Login handler token-issue block** (lines 277–279):
|
||||||
|
```python
|
||||||
|
access_token = auth_service.create_access_token(str(user.id), user.role)
|
||||||
|
raw_refresh = await auth_service.create_refresh_token(session, user.id)
|
||||||
|
_set_refresh_cookie(response, raw_refresh)
|
||||||
|
```
|
||||||
|
Pass `remember_me` through:
|
||||||
|
```python
|
||||||
|
access_token = auth_service.create_access_token(str(user.id), user.role)
|
||||||
|
raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
|
||||||
|
_set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/src/views/auth/LoginView.vue` — "Stay signed in" checkbox
|
||||||
|
|
||||||
|
**Analog:** self — the password step form (lines 1–59 of template); the `submitPassword` / `submitTotp` / `submitBackupCode` functions (lines 224–265).
|
||||||
|
|
||||||
|
**Current reactive state** (lines 188–193):
|
||||||
|
```javascript
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const totpInput = ref('')
|
||||||
|
const backupCodeInput = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
```
|
||||||
|
Add one ref:
|
||||||
|
```javascript
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checkbox markup** — insert inside the password step form (`<form @submit.prevent="submitPassword">`), between the password `<div>` block and the error `<div>`. Copy existing input style (Tailwind classes) from the form:
|
||||||
|
```html
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="rememberMe"
|
||||||
|
id="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label for="remember-me" class="text-sm text-gray-600">
|
||||||
|
Stay signed in for 30 days
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`submitPassword` update** (lines 224–235):
|
||||||
|
```javascript
|
||||||
|
async function submitPassword() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await authStore.login(email.value, password.value)
|
||||||
|
await handleLoginResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Change the `authStore.login` call to pass `rememberMe`:
|
||||||
|
```javascript
|
||||||
|
const result = await authStore.login(email.value, password.value, {
|
||||||
|
rememberMe: rememberMe.value,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`submitTotp` and `submitBackupCode`** — these already pass `options` objects (lines 241–264). Add `rememberMe: rememberMe.value` to each call's options object, alongside the existing `totpCode` / `backupCode` keys. The checkbox state persists across steps since it is a single `ref` scoped to the component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/src/stores/auth.js` — `login()` action
|
||||||
|
|
||||||
|
**Analog:** self — `login()` at lines 60–88.
|
||||||
|
|
||||||
|
**Current `login()` action** (lines 60–88):
|
||||||
|
```javascript
|
||||||
|
async function login(email, password, options = {}) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.login({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
totp_code: options.totpCode ?? null,
|
||||||
|
backup_code: options.backupCode ?? null,
|
||||||
|
})
|
||||||
|
// ... response handling unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `remember_me` to the API call body:
|
||||||
|
```javascript
|
||||||
|
remember_me: options.rememberMe ?? false,
|
||||||
|
```
|
||||||
|
Insert after `backup_code`. The rest of `login()` is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `backend/tests/test_auth_es256.py` — NEW test file
|
||||||
|
|
||||||
|
**Analog:** `backend/tests/test_task2_auth_service.py` (unit pattern) + `backend/tests/test_auth_api.py` (integration + FakeRedis pattern)
|
||||||
|
|
||||||
|
**File header pattern** (from `test_task2_auth_service.py` lines 1–7):
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
TDD tests for Phase 7.3: ES256 algorithm upgrade, startup token rotation,
|
||||||
|
and remember_me session TTL.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unit test structure** (from `test_task2_auth_service.py` lines 28–57 — no async, no DB):
|
||||||
|
```python
|
||||||
|
def test_access_token_uses_es256():
|
||||||
|
from services.auth import create_access_token
|
||||||
|
t = create_access_token("test-uid", "user")
|
||||||
|
# Header is base64url({"alg":"ES256",...}) — check alg claim
|
||||||
|
import base64, json
|
||||||
|
header = json.loads(base64.urlsafe_b64decode(t.split(".")[0] + "=="))
|
||||||
|
assert header["alg"] == "ES256"
|
||||||
|
```
|
||||||
|
|
||||||
|
**xfail stub pattern** — for tests needing DB fixtures not yet wired, use `pytest.mark.xfail(strict=True, reason="...")`. Follow the existing xfail convention in the test suite (used in Phase 7.2 test stubs). Stubs that need `db_session` use the `async def` + `pytest_asyncio.fixture` pattern from `conftest.py`:
|
||||||
|
```python
|
||||||
|
@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_startup_rotation_revokes_tokens(db_session):
|
||||||
|
pytest.xfail("not yet implemented")
|
||||||
|
```
|
||||||
|
|
||||||
|
**FakeRedis pattern** — for integration tests hitting the login endpoint, import and apply the `FakeRedis` from `test_auth_api.py`:
|
||||||
|
```python
|
||||||
|
from test_auth_api import FakeRedis, _register, _login
|
||||||
|
```
|
||||||
|
Or copy the minimal `FakeRedis` class and `_login` helper into the new file — the `test_auth_api.py` pattern is lines 47–97 of that file.
|
||||||
|
|
||||||
|
**Key env var fixture** — because ES256 needs real key material in `settings`, add a module-level fixture that patches settings before the module runs:
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def es256_keys(monkeypatch):
|
||||||
|
"""Patch settings with a freshly generated P-256 key pair for each test."""
|
||||||
|
k = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
priv = base64.b64encode(
|
||||||
|
k.private_bytes(serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption())
|
||||||
|
).decode()
|
||||||
|
pub = base64.b64encode(
|
||||||
|
k.public_key().public_bytes(serialization.Encoding.PEM,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
).decode()
|
||||||
|
monkeypatch.setattr("config.settings.jwt_private_key", priv)
|
||||||
|
monkeypatch.setattr("config.settings.jwt_public_key", pub)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`conftest.py` `auth_user` fixture** (lines 207–245) — the integration tests that assert on refresh-token TTL need a `db_session` + `async_client`. Reuse these fixtures from `conftest.py` directly (they are autoimported by pytest from the package-level conftest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### Base64-decoded secret inline at call site
|
||||||
|
**Source:** `backend/services/ai_config.py` lines 63–71 (HKDF derive pattern)
|
||||||
|
**Apply to:** All 4 JWT signing/decoding sites in `services/auth.py`
|
||||||
|
```python
|
||||||
|
# Pattern: decode inline, never cache as module-level global
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Startup idempotency check (SystemSettings read + upsert)
|
||||||
|
**Source:** `backend/services/ai_config.py:seed_system_settings_from_env` lines 219–244
|
||||||
|
**Apply to:** `_rotate_tokens_on_algorithm_change` in `main.py`
|
||||||
|
```python
|
||||||
|
stmt = select(SystemSettings).where(SystemSettings.provider_id == "<key>")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return # idempotent — skip
|
||||||
|
# ... insert or update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Startup try/except wrapping for missing table
|
||||||
|
**Source:** `backend/main.py` lines 172–180
|
||||||
|
**Apply to:** ES256 rotation block in `main.py`
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await <startup_fn>(session)
|
||||||
|
except Exception as _exc:
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger(__name__).warning(
|
||||||
|
"<task> skipped (table may not exist yet): %s", _exc
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### httpOnly cookie set pattern
|
||||||
|
**Source:** `backend/api/auth.py:_set_refresh_cookie` lines 70–80
|
||||||
|
**Apply to:** modified `_set_refresh_cookie` signature (add `remember_me` param)
|
||||||
|
```python
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=raw_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
max_age=<computed>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue 3 Options API checkbox (script setup)
|
||||||
|
**Source:** `frontend/src/views/auth/LoginView.vue` lines 177–193 (existing ref pattern)
|
||||||
|
**Apply to:** new `rememberMe` ref + checkbox in `LoginView.vue`
|
||||||
|
```javascript
|
||||||
|
// Existing pattern: all form state is a ref()
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
|
// Existing options object pattern (submitTotp, line 241):
|
||||||
|
const result = await authStore.login(email.value, password.value, {
|
||||||
|
totpCode: totpInput.value,
|
||||||
|
})
|
||||||
|
// Extend with:
|
||||||
|
rememberMe: rememberMe.value,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk SQL update (no Python-layer iteration)
|
||||||
|
**Source:** `backend/services/auth.py:revoke_all_refresh_tokens` provides context; the **preferred pattern for bulk updates** in this codebase is a single `session.execute(text(...))` as documented in CONTEXT.md §Specific Ideas
|
||||||
|
**Apply to:** `_rotate_tokens_on_algorithm_change` bulk revoke
|
||||||
|
```python
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
All files have close analogs. No gaps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** `backend/services/`, `backend/api/`, `backend/config.py`, `backend/main.py`, `frontend/src/stores/`, `frontend/src/views/auth/`, `backend/tests/`
|
||||||
|
**Files read:** 12 source files + 2 planning documents
|
||||||
|
**Pattern extraction date:** 2026-06-05
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
# Phase 07.3: Security — ES256 Algorithm Upgrade - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-06-05
|
||||||
|
**Domain:** JWT cryptographic algorithm upgrade, ECDSA P-256, FastAPI lifespan startup hooks, Vue 3 login form
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**D-01:** Both `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` are **required** env vars. Both store the PEM key **base64-encoded** as a single line — shell-safe, no newline-escape complications. `config.py` decodes each value before passing to PyJWT.
|
||||||
|
|
||||||
|
**D-02:** `create_access_token` and `create_password_reset_token` sign with the private key using `algorithm="ES256"`. `decode_access_token` and `decode_password_reset_token` verify with the public key using `algorithms=["ES256"]`.
|
||||||
|
|
||||||
|
**D-03:** `settings.secret_key` is **removed from all JWT code** after ES256 is in place. It is no longer used for JWT signing. The `SECRET_KEY` env var stays in docker-compose for any future non-JWT HMAC needs (Phase 7.4 fingerprinting), but `services/auth.py` must not reference it.
|
||||||
|
|
||||||
|
**D-04:** On FastAPI `lifespan` startup, compare the `jwt_algorithm` key in the `system_settings` table (or its absence) against the current algorithm `"ES256"`. If the stored value differs (e.g., was `"HS256"` or is missing), bulk-revoke all `RefreshToken` rows by executing `UPDATE refresh_tokens SET revoked = true WHERE revoked = false`, then upsert `jwt_algorithm = "ES256"` into `system_settings`. This is idempotent — safe to restart.
|
||||||
|
|
||||||
|
**D-05:** Use the existing `system_settings` upsert pattern from `backend/services/ai_config.py` (`seed_system_settings_from_env`) as the reference for reading/writing system settings in the startup hook.
|
||||||
|
|
||||||
|
**D-06:** Document key generation as a **Python one-liner** in `README.md` using the `cryptography` library (already a project dependency). The snippet prints both base64-encoded keys ready to paste into `.env`. No extra tooling required.
|
||||||
|
|
||||||
|
**D-07:** `docker-compose.yml` references `${JWT_PRIVATE_KEY}` and `${JWT_PUBLIC_KEY}` from the `.env` file — same pattern as the existing `${SECRET_KEY}`. No placeholder values in the repo.
|
||||||
|
|
||||||
|
**D-08:** Access token TTL: **15 minutes** — unchanged and non-negotiable (CLAUDE.md).
|
||||||
|
|
||||||
|
**D-09:** Default refresh token TTL: **16 hours** (new config setting `refresh_token_expire_hours: int = 16`).
|
||||||
|
|
||||||
|
**D-10:** "Remember me" opt-in: **30 days** (existing `refresh_token_expire_days: int = 30` kept as-is).
|
||||||
|
|
||||||
|
**D-11:** `POST /api/auth/login` request body gains `remember_me: bool = False`. `create_refresh_token` gains `remember_me: bool = False` param — selects between `timedelta(hours=settings.refresh_token_expire_hours)` and `timedelta(days=settings.refresh_token_expire_days)`.
|
||||||
|
|
||||||
|
**D-12:** `LoginView.vue` gains a "Stay signed in for 30 days" checkbox that passes `remember_me` to `authStore.login()`. The store passes it through to the API call.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
None specified.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = `hmac(key, User-Agent + Accept-Language)[:16]`.
|
||||||
|
- **Key rotation ceremony**: Dual-key overlap period for production P-256 key rotation.
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
|
||||||
|
| Directive | Impact on Phase 7.3 |
|
||||||
|
|-----------|---------------------|
|
||||||
|
| JWT access token in Pinia memory only — never localStorage | No change; access token delivery pattern unchanged |
|
||||||
|
| Access token TTL: 15 minutes maximum (non-negotiable) | D-08 locked; `access_token_expire_minutes` stays at 15 |
|
||||||
|
| Refresh token: httpOnly Strict cookie; rotated on every use | Cookie-setting helpers must pass `remember_me` through |
|
||||||
|
| Admin endpoints never return `password_hash`, `credentials_enc`, or document content | Not directly relevant; no new admin endpoints |
|
||||||
|
| No raw string interpolation in DB queries | Bulk revoke uses `text("UPDATE …")` — parameterized; no user input injected |
|
||||||
|
| Service layer raises `ValueError`, never `HTTPException` | ES256 config validation in `config.py` validates before service layer, not inside it |
|
||||||
|
| No function in `services/auth.py` imports `HTTPException` | ES256 key decode failures must raise `ValueError` or let startup crash with a descriptive message |
|
||||||
|
| Existing test gate: `pytest -v` zero failures before phase advances | `test_settings_has_jwt_config` asserts `refresh_token_expire_days == 30` — this passes since we keep that field; new `refresh_token_expire_hours` assertion needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 7.3 replaces HS256 with ES256 (ECDSA P-256) across the four JWT signing/verification sites in `backend/services/auth.py`, adds a first-boot startup hook to bulk-revoke all active refresh tokens whenever the algorithm changes, and introduces a "remember me" opt-in that changes the default refresh token TTL from 30 days to 16 hours.
|
||||||
|
|
||||||
|
**The algorithmic change is surgical.** PyJWT 2.13.0 (installed and verified on this system) supports ES256 natively via `jwt.algorithms.ECAlgorithm`. The `cryptography` library (already a project dependency, `>=41.0.0`) provides the P-256 key generation primitives. No new packages are required. The upgrade touches 4 lines in `services/auth.py`, 3 settings in `config.py`, 2 lines in `docker-compose.yml`, and one `README.md` key-generation snippet.
|
||||||
|
|
||||||
|
**The startup token rotation is idempotent.** It uses the `system_settings` table's `provider_id` uniqueness to track the deployed algorithm (`provider_id = "jwt_algorithm"`, `model_name = "ES256"`). On every boot, the lifespan reads this value; if it differs from `"ES256"`, it bulk-revokes all active refresh tokens and writes the new value. Subsequent restarts skip the revocation. The bulk revoke uses a single raw `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` via `session.execute(text(...))` — no Python-layer iteration, consistent with the project pattern for bulk operations.
|
||||||
|
|
||||||
|
**The "remember me" change is a controlled TTL expansion.** The default session drops from 30 days to 16 hours (`refresh_token_expire_hours: int = 16` added to config). An opt-in `remember_me: bool = False` field on the `LoginRequest` Pydantic model controls which TTL path `create_refresh_token` takes. Both the cookie `max_age` and the `expires_at` DB column must be updated consistently.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in three independent but ordered waves: (1) ES256 key infrastructure + 4 signing sites, (2) startup rotation check, (3) remember-me TTL + frontend checkbox. Each wave is independently testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| JWT signing key pair storage | Backend config (env vars) | — | Private key must never leave the backend; env var is the standard secret injection pattern |
|
||||||
|
| JWT encoding (ES256) | Backend service (`services/auth.py`) | — | All token creation is encapsulated in the service layer per CLAUDE.md rules |
|
||||||
|
| JWT decoding / verification | Backend deps (`deps/auth.py` → `services/auth.py`) | — | `get_current_user` dependency calls `decode_access_token` from service layer |
|
||||||
|
| Startup token rotation | Backend lifespan (`main.py`) | DB (`system_settings`) | Lifespan owns startup tasks; DB tracks idempotency state |
|
||||||
|
| Refresh token TTL selection | Backend service (`services/auth.py:create_refresh_token`) | Backend API (`api/auth.py:login`) | Service makes TTL decision; API passes the flag through |
|
||||||
|
| Cookie max_age alignment | Backend API (`api/auth.py:_set_refresh_cookie`) | — | Cookie must mirror DB `expires_at` TTL exactly |
|
||||||
|
| "Stay signed in" checkbox UI | Frontend view (`LoginView.vue`) | Frontend store (`stores/auth.js`) | View owns form state; store owns API call forwarding |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (already installed — no new packages)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| PyJWT | `>=2.8.0` (2.13.0 installed) | JWT encode/decode with ES256 | Official Python JWT library; `ECAlgorithm` class confirmed present; ES256 encode/decode verified working on this system [VERIFIED: pip show PyJWT] |
|
||||||
|
| cryptography | `>=41.0.0` | P-256 key generation and PEM serialization | Already project dependency for HKDF/Fernet; `ec.generate_private_key(ec.SECP256R1())` and `serialization.Encoding.PEM` APIs verified working on this system [VERIFIED: runtime test] |
|
||||||
|
|
||||||
|
### No New Packages Required
|
||||||
|
|
||||||
|
All cryptographic primitives for ES256 are covered by the two libraries above, both already pinned in `requirements.txt`. No additional installs needed for this phase.
|
||||||
|
|
||||||
|
**Version verification:**
|
||||||
|
```bash
|
||||||
|
pip show PyJWT # → 2.13.0
|
||||||
|
pip show cryptography # → already installed (41.x or higher)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
No new packages are introduced in this phase. All cryptographic work uses `PyJWT` (already pinned) and `cryptography` (already pinned). Slopcheck is not required.
|
||||||
|
|
||||||
|
| Package | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| PyJWT | Already in requirements.txt — no change | ES256 support confirmed [VERIFIED: runtime test] |
|
||||||
|
| cryptography | Already in requirements.txt — no change | P-256 key generation confirmed [VERIFIED: runtime test] |
|
||||||
|
|
||||||
|
**Packages removed due to slopcheck verdict:** none
|
||||||
|
**Packages flagged as suspicious:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
.env file
|
||||||
|
JWT_PRIVATE_KEY (base64 PEM)
|
||||||
|
JWT_PUBLIC_KEY (base64 PEM)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
config.py (Settings)
|
||||||
|
jwt_private_key: str → base64.b64decode().decode() → PEM string
|
||||||
|
jwt_public_key: str → base64.b64decode().decode() → PEM string
|
||||||
|
refresh_token_expire_hours: int = 16
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
│
|
||||||
|
├──► services/auth.py
|
||||||
|
│ create_access_token() jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
│ decode_access_token() jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
│ create_password_reset_token() jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
│ decode_password_reset_token() jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
│ create_refresh_token(remember_me=False)
|
||||||
|
│ TTL = timedelta(hours=16) if not remember_me
|
||||||
|
│ = timedelta(days=30) if remember_me
|
||||||
|
│
|
||||||
|
├──► main.py lifespan (startup)
|
||||||
|
│ ES256 rotation check:
|
||||||
|
│ SELECT model_name FROM system_settings WHERE provider_id = 'jwt_algorithm'
|
||||||
|
│ if missing or != 'ES256':
|
||||||
|
│ UPDATE refresh_tokens SET revoked = true WHERE revoked = false
|
||||||
|
│ UPSERT system_settings (provider_id='jwt_algorithm', model_name='ES256')
|
||||||
|
│
|
||||||
|
└──► api/auth.py
|
||||||
|
LoginRequest.remember_me: bool = False
|
||||||
|
_set_refresh_cookie(response, raw_token, remember_me)
|
||||||
|
max_age = 30*86400 if remember_me else 16*3600
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
LoginView.vue
|
||||||
|
[ ] Stay signed in for 30 days ← new checkbox
|
||||||
|
submitPassword() / submitTotp() / submitBackupCode()
|
||||||
|
→ authStore.login(email, password, { ..., rememberMe: bool })
|
||||||
|
|
||||||
|
stores/auth.js
|
||||||
|
login(email, password, options)
|
||||||
|
api.login({ ..., remember_me: options.rememberMe ?? false })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
No new directories. Changes are concentrated in:
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── config.py # +jwt_private_key, +jwt_public_key, +refresh_token_expire_hours
|
||||||
|
├── services/auth.py # 4 jwt.encode/decode sites; create_refresh_token TTL
|
||||||
|
├── main.py # lifespan: +ES256 rotation block
|
||||||
|
├── api/auth.py # LoginRequest +remember_me; _set_refresh_cookie +remember_me
|
||||||
|
frontend/
|
||||||
|
├── src/views/auth/LoginView.vue # +checkbox, +rememberMe ref, pass through to store
|
||||||
|
└── src/stores/auth.js # login() +rememberMe param pass-through
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: ES256 Key Decode in config.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: verified via runtime test + PyJWT docs
|
||||||
|
import base64
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# ... existing fields ...
|
||||||
|
jwt_private_key: str = "" # base64-encoded PEM — required in production
|
||||||
|
jwt_public_key: str = "" # base64-encoded PEM — required in production
|
||||||
|
refresh_token_expire_hours: int = 16 # default short session (D-09)
|
||||||
|
# refresh_token_expire_days: int = 30 already exists — keep unchanged
|
||||||
|
|
||||||
|
def get_jwt_private_key_pem(self) -> str:
|
||||||
|
"""Decode base64-encoded private key PEM string for PyJWT."""
|
||||||
|
return base64.b64decode(self.jwt_private_key).decode()
|
||||||
|
|
||||||
|
def get_jwt_public_key_pem(self) -> str:
|
||||||
|
"""Decode base64-encoded public key PEM string for PyJWT."""
|
||||||
|
return base64.b64decode(self.jwt_public_key).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative (simpler, no property):** Decode inline at call site in `services/auth.py`:
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
Either approach is acceptable. The inline pattern avoids adding methods to Settings and is consistent with how other base64 fields (like cloud credentials) are handled in this codebase.
|
||||||
|
|
||||||
|
### Pattern 2: ES256 JWT Signing/Verification (4 sites)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: verified via runtime test — PyJWT 2.13.0 + cryptography 41.x
|
||||||
|
import base64, jwt
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# BEFORE (HS256):
|
||||||
|
# return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
# payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||||
|
|
||||||
|
# AFTER (ES256):
|
||||||
|
def create_access_token(user_id: str, role: str) -> str:
|
||||||
|
# ... payload construction unchanged ...
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise ValueError("Token has expired") from exc
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise ValueError(f"Invalid token: {exc}") from exc
|
||||||
|
# ... type check unchanged ...
|
||||||
|
return payload
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern applies identically to `create_password_reset_token` (sign) and `decode_password_reset_token` (verify).
|
||||||
|
|
||||||
|
### Pattern 3: Startup Token Rotation (lifespan hook)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: D-04/D-05 from CONTEXT.md; modeled after seed_system_settings_from_env pattern
|
||||||
|
from sqlalchemy import text, select
|
||||||
|
from db.models import SystemSettings
|
||||||
|
|
||||||
|
async def _rotate_tokens_on_algorithm_change(session: AsyncSession) -> None:
|
||||||
|
"""Idempotent ES256 migration: bulk-revoke tokens if algorithm changed."""
|
||||||
|
stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
current_alg = row.model_name if row is not None else None
|
||||||
|
if current_alg == "ES256":
|
||||||
|
return # Already migrated — skip
|
||||||
|
|
||||||
|
# Algorithm changed or first boot: bulk-revoke all active refresh tokens
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert the algorithm marker
|
||||||
|
if row is None:
|
||||||
|
import uuid as _uuid
|
||||||
|
session.add(SystemSettings(
|
||||||
|
id=_uuid.uuid4(),
|
||||||
|
provider_id="jwt_algorithm",
|
||||||
|
model_name="ES256",
|
||||||
|
context_chars=0,
|
||||||
|
is_active=False,
|
||||||
|
api_key_enc=None,
|
||||||
|
base_url=None,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
row.model_name = "ES256"
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
This function slots into `main.py:lifespan` after the AI seed block, wrapped in the same `try/except` pattern (so a missing `system_settings` table before migrations doesn't crash startup).
|
||||||
|
|
||||||
|
### Pattern 4: remember_me TTL Selection
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: D-09/D-10/D-11 from CONTEXT.md
|
||||||
|
|
||||||
|
async def create_refresh_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
remember_me: bool = False, # NEW PARAM
|
||||||
|
) -> str:
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Select TTL based on remember_me
|
||||||
|
if remember_me:
|
||||||
|
ttl = timedelta(days=settings.refresh_token_expire_days) # 30 days
|
||||||
|
else:
|
||||||
|
ttl = timedelta(hours=settings.refresh_token_expire_hours) # 16 hours
|
||||||
|
row = RefreshToken(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=now + ttl,
|
||||||
|
revoked=False,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return raw
|
||||||
|
```
|
||||||
|
|
||||||
|
The `_set_refresh_cookie` helper also needs a `remember_me` param to set `max_age` consistently:
|
||||||
|
```python
|
||||||
|
def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None:
|
||||||
|
max_age = (
|
||||||
|
settings.refresh_token_expire_days * 86400
|
||||||
|
if remember_me
|
||||||
|
else settings.refresh_token_expire_hours * 3600
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=raw_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
max_age=max_age,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Key Generation One-Liner (for README.md)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source: verified via runtime test — cryptography library
|
||||||
|
python3 -c "
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
import base64
|
||||||
|
k = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
|
||||||
|
pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
|
||||||
|
print(f'JWT_PRIVATE_KEY={priv}')
|
||||||
|
print(f'JWT_PUBLIC_KEY={pub}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
This outputs two single-line values ready to paste into `.env`. The `cryptography` library is already a project dependency so no install is needed.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Caching decoded PEM strings as module-level globals:** If the env var changes between tests or deployments and the module was already imported, stale keys would be used. Decode inline at each call site or in a `@cached_property` that is tied to the Settings instance.
|
||||||
|
- **Passing `settings.secret_key` to any JWT function after ES256 is deployed:** D-03 explicitly removes this coupling. If any site still uses `secret_key`, tests will catch it (HS256 tokens rejected by ES256 verifier).
|
||||||
|
- **Calling `create_refresh_token` in `rotate_refresh_token` without forwarding `remember_me`:** `rotate_refresh_token` calls `create_refresh_token(session, row.user_id)` internally. This internal call should NOT receive `remember_me` — token rotation preserves the session type from the original login. The new `remember_me` param defaults to `False`, so the rotation call is unchanged; a rotated token always gets the default 16-hour TTL (which is acceptable security behavior — the client gets a fresh cookie with the new token).
|
||||||
|
- **Inserting a `jwt_algorithm` row into `system_settings` with `is_active=True`:** This would make the AI provider loader (`load_provider_config`) potentially attempt to use "jwt_algorithm" as a provider. Always insert with `is_active=False`.
|
||||||
|
- **Using `model_name` for arbitrary key-value storage without documenting the overloading:** The `SystemSettings.model_name` column is `NOT NULL TEXT` — it can hold "ES256" safely. Document in the startup function's docstring that this is a metadata row, not an AI provider row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| ECDSA key generation | Custom byte manipulation | `ec.generate_private_key(ec.SECP256R1())` from `cryptography` | cryptography handles FIPS-compliant randomness, correct curve encoding, and PEM serialization |
|
||||||
|
| ES256 JWT signing | Manual ECDSA signature + base64url encode | `jwt.encode(payload, pem, algorithm="ES256")` from PyJWT | PyJWT handles all RFC 7518 compliance, header formatting, and signature encoding |
|
||||||
|
| ES256 JWT verification | Manual signature extraction + ECDSA verify | `jwt.decode(token, pem, algorithms=["ES256"])` from PyJWT | PyJWT validates expiry, algorithm, and signature in one call; algorithm-restriction list prevents downgrade attacks |
|
||||||
|
| Bulk token revocation | Python loop loading all RefreshToken rows | `session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false"))` | Single SQL statement vs. N Python-layer round trips; consistent with project bulk-operation pattern |
|
||||||
|
|
||||||
|
**Key insight:** The `cryptography` + `PyJWT` combination handles all ECDSA P-256 complexity. There is no case in this phase where custom cryptographic code is appropriate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: PEM Newlines in Environment Variables
|
||||||
|
**What goes wrong:** A raw PEM string contains newlines. Shell `.env` files and Docker Compose do not handle multi-line values well — the variable gets truncated at the first newline.
|
||||||
|
**Why it happens:** PEM format is inherently multi-line. Naive `JWT_PRIVATE_KEY=$(cat private.pem)` breaks.
|
||||||
|
**How to avoid:** Store as base64-encoded single line (D-01). `base64.b64encode(pem_bytes).decode()` produces a single-line string with no newlines (standard base64 with no padding issues for a ~PKCS8 key). Decode at use with `base64.b64decode(settings.jwt_private_key).decode()`.
|
||||||
|
**Warning signs:** `binascii.Error: Invalid base64-encoded string` or `jwt.exceptions.DecodeError: Invalid header string` at startup.
|
||||||
|
|
||||||
|
### Pitfall 2: Algorithm Confusion / Downgrade Attack During Transition
|
||||||
|
**What goes wrong:** Old clients hold HS256 tokens. After ES256 deployment, `decode_access_token` now calls `jwt.decode(token, pub_pem, algorithms=["ES256"])`. An HS256 token presented to the ES256 verifier raises `InvalidAlgorithmError` which maps to 401 — correct behavior.
|
||||||
|
**Why it happens:** Clients with a valid HS256 access token (up to 15 min old) will get 401 until their token expires. This is expected and correct — they will auto-refresh via the httpOnly cookie, which triggers the rotation. The startup hook bulk-revokes all refresh tokens, forcing re-login.
|
||||||
|
**How to avoid:** Accept this brief disruption (max 15 minutes for any in-flight session). Document in README.md. No dual-algorithm support is needed since the bulk-revoke forces re-login on first boot.
|
||||||
|
**Warning signs:** Users report "logged out" immediately after deploy — this is expected.
|
||||||
|
|
||||||
|
### Pitfall 3: SystemSettings Model Incompatibility for jwt_algorithm Row
|
||||||
|
**What goes wrong:** `SystemSettings.model_name` is `NOT NULL` with `default=""`. `context_chars` is `NOT NULL` with `default=8000`. Inserting a `jwt_algorithm` marker row requires satisfying these constraints without meaningful values.
|
||||||
|
**Why it happens:** The table was designed for AI provider config, not generic key-value storage.
|
||||||
|
**How to avoid:** Insert with `model_name="ES256"`, `context_chars=0`, `is_active=False`, `api_key_enc=None`, `base_url=None`. This satisfies all NOT NULL constraints. Use `is_active=False` so the AI provider loader (`load_provider_config`) never returns this row. Verified against the model definition at `db/models.py` line 340.
|
||||||
|
**Warning signs:** `sqlalchemy.exc.IntegrityError` on NOT NULL violation if `model_name` is omitted.
|
||||||
|
|
||||||
|
### Pitfall 4: _set_refresh_cookie Called in Refresh Endpoint Without remember_me
|
||||||
|
**What goes wrong:** `_set_refresh_cookie` is called in both the `login` endpoint (new `remember_me` param) and the `refresh_token` endpoint (token rotation, no `remember_me`). If the refresh endpoint passes `remember_me=False` (default), rotating a 30-day session silently downgrades it to 16 hours.
|
||||||
|
**Why it happens:** Token rotation in `rotate_refresh_token` doesn't know what TTL the original token had — `expires_at` is on the old row, which is being revoked.
|
||||||
|
**How to avoid:** For the rotation endpoint, preserve the original cookie's `max_age` by reading the remaining TTL from the new token's `expires_at` and computing `max_age = int((row.expires_at - now).total_seconds())` on the newly-created row. Alternatively, accept that rotated sessions default to 16 hours (acceptable since the user can re-login with remember_me). The CONTEXT.md does not require preservation of remember_me state across rotation, so the simpler approach (default TTL on rotation) is acceptable.
|
||||||
|
**Warning signs:** Users who selected "remember me" get logged out after 16 hours despite the checkbox.
|
||||||
|
|
||||||
|
> **Decision required by planner:** Either preserve the TTL on rotation (complex — needs to pass remember_me through rotate_refresh_token) or accept 16-hour default on rotation (simple and acceptable). The CONTEXT.md is silent on this. The simpler approach is recommended for this phase.
|
||||||
|
|
||||||
|
### Pitfall 5: Existing Test Asserts on Settings Value
|
||||||
|
**What goes wrong:** `backend/tests/test_task1_models_config.py:31` asserts `settings.refresh_token_expire_days == 30`. Adding `refresh_token_expire_hours` does not break this assertion since the field value is unchanged.
|
||||||
|
**Why it happens:** Phase 2 locked the TTL value in a test. Phase 7.3 adds a new field but doesn't change the old one.
|
||||||
|
**How to avoid:** The test continues to pass. A new test should assert `settings.refresh_token_expire_hours == 16` and that `refresh_token_expire_days` remains 30.
|
||||||
|
**Warning signs:** Test file `test_task1_models_config.py` — check that no assertion fails after adding `refresh_token_expire_hours`.
|
||||||
|
|
||||||
|
### Pitfall 6: Lifespan Startup Order — System Settings Table May Not Exist
|
||||||
|
**What goes wrong:** The startup rotation check queries `system_settings`. On a fresh container before migrations run, the table doesn't exist and the query raises.
|
||||||
|
**Why it happens:** Docker healthchecks ensure DB is ready, but the migration step may not have run yet (developer workflow).
|
||||||
|
**How to avoid:** Wrap the ES256 rotation block in the same `try/except Exception` pattern already used for `seed_system_settings_from_env` in `main.py:lifespan` (lines 172–180). Log a warning and skip if the table is missing. On the next boot (after migrations), the check runs successfully.
|
||||||
|
**Warning signs:** `sqlalchemy.exc.ProgrammingError: relation "system_settings" does not exist` at startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from official sources:
|
||||||
|
|
||||||
|
### Full ES256 Signing Chain (verified at research time)
|
||||||
|
```python
|
||||||
|
# Source: verified via runtime test on this system (PyJWT 2.13.0 + cryptography 41.x)
|
||||||
|
import base64, jwt, uuid, datetime as dt
|
||||||
|
|
||||||
|
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||||
|
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||||
|
|
||||||
|
# Sign
|
||||||
|
now = dt.datetime.now(dt.timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"typ": "access",
|
||||||
|
"jti": str(uuid.uuid4()), # Phase 7.2 adds this claim; preserved in ES256
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, private_pem, algorithm="ES256")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
decoded = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||||
|
# algorithms=["ES256"] is a whitelist — HS256 tokens raise InvalidAlgorithmError (verified)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Revoke + System Settings Upsert (startup)
|
||||||
|
```python
|
||||||
|
# Source: D-04/D-05 from CONTEXT.md; SQLAlchemy text() pattern from codebase
|
||||||
|
from sqlalchemy import text, select
|
||||||
|
from db.models import SystemSettings
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None or row.model_name != "ES256":
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
session.add(SystemSettings(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
provider_id="jwt_algorithm",
|
||||||
|
model_name="ES256",
|
||||||
|
context_chars=0,
|
||||||
|
is_active=False,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
row.model_name = "ES256"
|
||||||
|
await session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### LoginRequest with remember_me (backend)
|
||||||
|
```python
|
||||||
|
# Source: CONTEXT.md D-11; modeled on existing LoginRequest pattern
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
totp_code: Optional[str] = None
|
||||||
|
backup_code: Optional[str] = None
|
||||||
|
remember_me: bool = False # NEW: opt-in to 30-day session
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login form checkbox (Vue 3 Options API → script setup)
|
||||||
|
```vue
|
||||||
|
<!-- Source: CONTEXT.md D-12; follows existing LoginView.vue pattern -->
|
||||||
|
<!-- Add inside the password step form, before the submit button: -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="rememberMe"
|
||||||
|
id="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label for="remember-me" class="text-sm text-gray-600">
|
||||||
|
Stay signed in for 30 days
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In script setup, add:
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
|
// Update submitPassword():
|
||||||
|
const result = await authStore.login(email.value, password.value, {
|
||||||
|
rememberMe: rememberMe.value,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth store login() update
|
||||||
|
```javascript
|
||||||
|
// Source: CONTEXT.md D-12; extends existing stores/auth.js login() action
|
||||||
|
async function login(email, password, options = {}) {
|
||||||
|
// ...
|
||||||
|
const data = await api.login({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
totp_code: options.totpCode ?? null,
|
||||||
|
backup_code: options.backupCode ?? null,
|
||||||
|
remember_me: options.rememberMe ?? false, // NEW
|
||||||
|
})
|
||||||
|
// ... rest unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| HS256 (symmetric HMAC) | ES256 (ECDSA P-256) asymmetric | This phase | Public key cannot forge tokens; leaked public key (safe to distribute) cannot impersonate users |
|
||||||
|
| 30-day default refresh TTL | 16-hour default; 30-day opt-in | This phase | Sessions expire overnight by default; lower risk for unattended sessions |
|
||||||
|
| `algorithms=["HS256"]` in decode | `algorithms=["ES256"]` whitelist | This phase | PyJWT whitelist prevents algorithm confusion attacks; HS256 tokens immediately rejected |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `settings.secret_key` as JWT signing key: After this phase, `services/auth.py` no longer references `secret_key` for JWT operations. The field stays in config for Phase 7.4 HMAC fingerprinting use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | The `model_name` field of `SystemSettings` being repurposed as a value store for `jwt_algorithm` will not trigger unexpected behavior in `load_provider_config()` since `is_active=False` prevents selection | Startup rotation | Low — verified that `load_provider_config` filters by `is_active IS TRUE`; `is_active=False` row is never returned as active provider |
|
||||||
|
| A2 | `rotate_refresh_token()` (called for normal token rotation, not the startup bulk-revoke) calls `create_refresh_token(session, row.user_id)` without `remember_me` — new default TTL (16h) will apply on every rotation | Pitfall 4 | Low risk for security (shorter is better); risk for UX (remember-me users get 16h after first rotation). Accepted per Pitfall 4 note. |
|
||||||
|
| A3 | Phase 7.2 adds a `jti` claim to access tokens. Since Phase 7.3 depends on Phase 7.2, the `create_access_token` payload will already contain `jti` when Phase 7.3 is implemented. The ES256 upgrade must preserve the `jti` field. | Code Examples | Low — verified: PyJWT preserves all custom claims through ES256 encode/decode. |
|
||||||
|
|
||||||
|
**If this table is empty:** All claims in this research were verified or cited. — Not applicable; three low-risk assumptions documented above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
|
1. **TTL preservation across refresh token rotation** (RESOLVED)
|
||||||
|
- What we know: `rotate_refresh_token` calls `create_refresh_token` internally with no `remember_me` param (default=False → 16 hours)
|
||||||
|
- RESOLVED: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). `rotate_refresh_token` is intentionally NOT updated. Documented in Plan 07.3-03 objective and acceptance criteria.
|
||||||
|
|
||||||
|
2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`** (RESOLVED)
|
||||||
|
- RESOLVED: Nothing — this test continues to pass. Plan 07.3-01-T2 adds assertion for `refresh_token_expire_hours == 16` to the same test function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| PyJWT | ES256 encoding/decoding | Yes | 2.13.0 | — |
|
||||||
|
| cryptography | P-256 key generation, PEM serialization | Yes | (>=41.0.0 pinned) | — |
|
||||||
|
| Python 3.12 | All backend code | Yes | 3.12 | — |
|
||||||
|
| PostgreSQL | `system_settings` table for algorithm tracking | Yes (Docker Compose) | 17 | — |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:** None.
|
||||||
|
**Missing dependencies with fallback:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | pytest + pytest-asyncio |
|
||||||
|
| Config file | `backend/pytest.ini` or `pyproject.toml` |
|
||||||
|
| Quick run command | `cd backend && pytest tests/test_auth_api.py tests/test_task1_models_config.py -x -v` |
|
||||||
|
| Full suite command | `cd backend && pytest -v` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| ES256-01 | `create_access_token` uses ES256 algorithm | unit | `pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x` | ❌ Wave 0 |
|
||||||
|
| ES256-02 | `decode_access_token` rejects HS256 tokens | unit | `pytest tests/test_auth_es256.py::test_hs256_token_rejected -x` | ❌ Wave 0 |
|
||||||
|
| ES256-03 | `create_password_reset_token` uses ES256 | unit | `pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x` | ❌ Wave 0 |
|
||||||
|
| ES256-04 | Startup rotation bulk-revokes on algorithm change | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x` | ❌ Wave 0 |
|
||||||
|
| ES256-05 | Startup rotation is idempotent (second boot skips) | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x` | ❌ Wave 0 |
|
||||||
|
| RM-01 | Default login issues 16-hour refresh token | integration | `pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x` | ❌ Wave 0 |
|
||||||
|
| RM-02 | remember_me=True issues 30-day refresh token | integration | `pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x` | ❌ Wave 0 |
|
||||||
|
| RM-03 | remember_me=True sets cookie max_age=30*86400 | integration | `pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x` | ❌ Wave 0 |
|
||||||
|
| CFG-01 | Settings has `jwt_private_key`, `jwt_public_key`, `refresh_token_expire_hours` | unit | `pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x` | ✅ (needs extension) |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
|
||||||
|
- **Per task commit:** `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v`
|
||||||
|
- **Per wave merge:** `cd backend && pytest -v`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
- [ ] `backend/tests/test_auth_es256.py` — covers ES256-01 through RM-03 (8 new tests)
|
||||||
|
- [ ] Extend `backend/tests/test_task1_models_config.py::test_settings_has_jwt_config` to assert `refresh_token_expire_hours == 16`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | yes | ES256 asymmetric tokens; `algorithms=["ES256"]` whitelist prevents downgrade |
|
||||||
|
| V3 Session Management | yes | Refresh token TTL split (16h default / 30d opt-in); bulk-revoke on algorithm change |
|
||||||
|
| V4 Access Control | no | No new access control logic |
|
||||||
|
| V5 Input Validation | yes | `remember_me: bool = False` typed Pydantic field; `base64.b64decode` validates key format at startup |
|
||||||
|
| V6 Cryptography | yes | ECDSA P-256 (256-bit EC key) via `cryptography` library; no hand-rolled crypto |
|
||||||
|
|
||||||
|
### Known Threat Patterns for JWT + ES256
|
||||||
|
|
||||||
|
| Pattern | STRIDE | Standard Mitigation |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| Algorithm confusion: HS256 token presented to ES256 verifier | Spoofing | `algorithms=["ES256"]` whitelist in `jwt.decode()` — raises `InvalidAlgorithmError` (verified) |
|
||||||
|
| None algorithm attack | Spoofing | PyJWT explicitly requires algorithms list; `algorithms=["ES256"]` prevents `alg=none` |
|
||||||
|
| Leaked public key token forgery | Spoofing | ES256 asymmetric design: public key cannot sign — forgery requires the private key |
|
||||||
|
| Private key exposure in env | Elevation of Privilege | Base64 single-line in `.env`; `.gitignore` enforces exclusion; no defaults in code (D-07) |
|
||||||
|
| Stale HS256 tokens after upgrade | Spoofing | Bulk refresh token revocation forces re-login; 15-min access token TTL self-expires |
|
||||||
|
| remember_me session indefinitely reused | Elevation of Privilege | RFC 9700 family revocation on reuse still applies; 30-day TTL is bounded |
|
||||||
|
|
||||||
|
### Security Gate Requirements
|
||||||
|
|
||||||
|
Before phase advances:
|
||||||
|
- [ ] `bandit -r backend/` — zero HIGH severity findings
|
||||||
|
- [ ] `pip audit` — zero critical/high CVEs
|
||||||
|
- [ ] `npm audit --audit-level=high` — zero high/critical
|
||||||
|
- [ ] HS256 token presented to ES256 verifier → 401 (negative test `test_hs256_token_rejected`)
|
||||||
|
- [ ] Startup rotation test: tokens revoked on algorithm change, skipped on same algorithm
|
||||||
|
- [ ] `services/auth.py` grep confirms zero references to `settings.secret_key` after implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
|
||||||
|
- PyJWT 2.13.0 installed on system — `ECAlgorithm`, ES256 encode/decode verified via runtime execution
|
||||||
|
- `cryptography` library installed — `ec.generate_private_key(ec.SECP256R1())`, `serialization.Encoding.PEM` verified via runtime execution
|
||||||
|
- `backend/services/auth.py` — 4 HS256 sites confirmed at lines 99, 109, 132, 141 (read directly)
|
||||||
|
- `backend/config.py` — current settings layout confirmed (read directly)
|
||||||
|
- `backend/main.py` — lifespan function structure confirmed at line 136 (read directly)
|
||||||
|
- `backend/services/ai_config.py` — `seed_system_settings_from_env` upsert reference pattern (read directly)
|
||||||
|
- `backend/db/models.py` — `SystemSettings` schema confirmed at lines 340–377; `RefreshToken.revoked` at line 102 (read directly)
|
||||||
|
- `backend/api/auth.py` — `LoginRequest`, `_set_refresh_cookie`, login handler confirmed (read directly)
|
||||||
|
- `frontend/src/views/auth/LoginView.vue` — step-based login form, `authStore.login()` at line 228 (read directly)
|
||||||
|
- `frontend/src/stores/auth.js` — `login()` action confirmed (read directly)
|
||||||
|
- `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md` — all 12 decisions confirmed (read directly)
|
||||||
|
- `backend/tests/test_task1_models_config.py:31` — existing assertion on `refresh_token_expire_days == 30` confirmed (read directly)
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
|
||||||
|
None required — all findings verified from code or runtime tests.
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
|
||||||
|
None — no claims rely on WebSearch-only sources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
|
||||||
|
- Standard stack: HIGH — PyJWT ES256 and cryptography P-256 generation both verified via runtime tests on the installed versions
|
||||||
|
- Architecture: HIGH — all 6 change sites read directly from codebase; data flow confirmed end-to-end
|
||||||
|
- Pitfalls: HIGH — pitfalls derived from concrete code inspection (existing constraints, test assertions, and the SystemSettings schema)
|
||||||
|
|
||||||
|
**Research date:** 2026-06-05
|
||||||
|
**Valid until:** 2026-07-05 (stable libraries; PyJWT API is backward-compatible since 2.0)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
phase: 7.3
|
||||||
|
slug: 07.3-security-es256-algorithm-upgrade-inserted
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-06-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 7.3 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | pytest + pytest-asyncio |
|
||||||
|
| **Config file** | `backend/pytest.ini` |
|
||||||
|
| **Quick run command** | `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v` |
|
||||||
|
| **Full suite command** | `cd backend && pytest -v` |
|
||||||
|
| **Estimated runtime** | ~30 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v`
|
||||||
|
- **After every plan wave:** Run `cd backend && pytest -v`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 30 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 7.3-01-01 | 01 | 0 | ES256-01..RM-03 | T-7.3-01 | Wave 0 xfail stubs for all 9 test cases | unit/integration | `pytest tests/test_auth_es256.py -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-01-02 | 01 | 1 | CFG-01 | — | `settings.jwt_private_key`, `settings.jwt_public_key`, `settings.refresh_token_expire_hours` all present | unit | `pytest tests/test_task1_models_config.py -x` | ✅ (extend) | ⬜ pending |
|
||||||
|
| 7.3-01-03 | 01 | 1 | ES256-01 | T-7.3-01 | `create_access_token` returns token with `alg=ES256` in header | unit | `pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-01-04 | 01 | 1 | ES256-02 | T-7.3-02 | HS256 token presented to ES256 verifier raises 401 | unit | `pytest tests/test_auth_es256.py::test_hs256_token_rejected -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-01-05 | 01 | 1 | ES256-03 | T-7.3-01 | `create_password_reset_token` uses ES256 | unit | `pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-01-06 | 01 | 1 | ES256-04 | T-7.3-03 | Startup rotation bulk-revokes all refresh tokens when algorithm changes | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-01-07 | 01 | 1 | ES256-05 | T-7.3-03 | Startup rotation skips revocation on second boot (idempotent) | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-02-01 | 02 | 2 | RM-01 | T-7.3-04 | Default login (no remember_me) issues 16-hour refresh token | integration | `pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-02-02 | 02 | 2 | RM-02 | T-7.3-04 | `remember_me=True` issues 30-day refresh token | integration | `pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
| 7.3-02-03 | 02 | 2 | RM-03 | T-7.3-04 | `remember_me=True` sets `Set-Cookie: Max-Age=2592000` | integration | `pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x` | ❌ Wave 0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `backend/tests/test_auth_es256.py` — 9 xfail stubs covering ES256-01 through RM-03
|
||||||
|
- [ ] `backend/tests/test_task1_models_config.py` — extend `test_settings_has_jwt_config` to assert `refresh_token_expire_hours == 16`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| "Stay signed in for 30 days" checkbox visible on login form | D-12 | Frontend visual verification | Load `http://localhost:5173/login`; checkbox appears below password field; unchecked by default |
|
||||||
|
| Login without checkbox issues ~16h cookie | RM-03 | Browser cookie inspection | Login without checkbox; open DevTools → Application → Cookies; `refresh_token` `Max-Age` ≈ 57600 |
|
||||||
|
| Login with checkbox issues 30-day cookie | RM-03 | Browser cookie inspection | Login with checkbox checked; `refresh_token` `Max-Age` = 2592000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 30s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -23,10 +23,12 @@ Security invariants:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from config import settings
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
@@ -351,6 +353,12 @@ async def update_user_status(
|
|||||||
if not body.is_active:
|
if not body.is_active:
|
||||||
# Revoke all refresh tokens on deactivation
|
# Revoke all refresh tokens on deactivation
|
||||||
await revoke_all_refresh_tokens(session, user.id)
|
await revoke_all_refresh_tokens(session, user.id)
|
||||||
|
# Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01)
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Security invariants:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
@@ -503,6 +504,12 @@ async def change_password(
|
|||||||
ip_address=_ip,
|
ip_address=_ip,
|
||||||
metadata_={"sessions_revoked": revoked},
|
metadata_={"sessions_revoked": revoked},
|
||||||
)
|
)
|
||||||
|
# Revoke any pre-change access tokens still within their TTL window (T-7.2-01)
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{current_user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"message": "Password updated", "sessions_revoked": revoked}
|
return {"message": "Password updated", "sessions_revoked": revoked}
|
||||||
@@ -596,6 +603,12 @@ async def enable_totp(
|
|||||||
ip_address=_ip,
|
ip_address=_ip,
|
||||||
metadata_={"sessions_revoked": revoked},
|
metadata_={"sessions_revoked": revoked},
|
||||||
)
|
)
|
||||||
|
# Revoke any pre-enroll access tokens still within their TTL window (T-7.2-01)
|
||||||
|
await redis_client.set(
|
||||||
|
f"user_nbf:{current_user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"backup_codes": plain_codes, "sessions_revoked": revoked}
|
return {"backup_codes": plain_codes, "sessions_revoked": revoked}
|
||||||
@@ -636,6 +649,12 @@ async def disable_totp(
|
|||||||
ip_address=_ip,
|
ip_address=_ip,
|
||||||
metadata_={"sessions_revoked": revoked},
|
metadata_={"sessions_revoked": revoked},
|
||||||
)
|
)
|
||||||
|
# Revoke any pre-revoke access tokens still within their TTL window (T-7.2-01)
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{current_user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"message": "TOTP disabled", "sessions_revoked": revoked}
|
return {"message": "TOTP disabled", "sessions_revoked": revoked}
|
||||||
@@ -680,6 +699,7 @@ async def password_reset_request(
|
|||||||
|
|
||||||
@router.post("/password-reset/confirm")
|
@router.post("/password-reset/confirm")
|
||||||
async def password_reset_confirm(
|
async def password_reset_confirm(
|
||||||
|
request: Request,
|
||||||
body: PasswordResetConfirmRequest,
|
body: PasswordResetConfirmRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -721,6 +741,12 @@ async def password_reset_confirm(
|
|||||||
# Update password and revoke all sessions (forces re-auth through TOTP if enabled)
|
# Update password and revoke all sessions (forces re-auth through TOTP if enabled)
|
||||||
user.password_hash = auth_service.hash_password(body.new_password)
|
user.password_hash = auth_service.hash_password(body.new_password)
|
||||||
await auth_service.revoke_all_refresh_tokens(session, user.id)
|
await auth_service.revoke_all_refresh_tokens(session, user.id)
|
||||||
|
# Revoke any pre-reset access tokens still within their TTL window (T-7.2-01)
|
||||||
|
await request.app.state.redis.set(
|
||||||
|
f"user_nbf:{user.id}",
|
||||||
|
int(time.time()),
|
||||||
|
ex=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login)
|
# Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Usage in route handlers:
|
|||||||
):
|
):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
@@ -30,6 +31,8 @@ from db.models import User
|
|||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
from services import auth as auth_service
|
from services import auth as auth_service
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# HTTPBearer parses the Authorization: Bearer <token> header.
|
# HTTPBearer parses the Authorization: Bearer <token> header.
|
||||||
# auto_error=True (default) raises 403 if no Authorization header is present.
|
# auto_error=True (default) raises 403 if no Authorization header is present.
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
@@ -56,6 +59,31 @@ async def get_current_user(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
# ── user_nbf check (D-02, D-03, D-04) ──────────────────────────────────────
|
||||||
|
# Reject tokens issued before a security event (password change, TOTP enroll,
|
||||||
|
# deactivation). The user_nbf:{user_id} key is written by Wave 2 (Plan 03).
|
||||||
|
# Fail-open on Redis errors (D-04): a Redis outage must never deny service.
|
||||||
|
# CRITICAL: the `except HTTPException: raise` guard MUST precede the broad
|
||||||
|
# `except Exception` — otherwise the intentional 401 is swallowed (T-7.2-02).
|
||||||
|
try:
|
||||||
|
redis_client = request.app.state.redis
|
||||||
|
nbf_bytes = await redis_client.get(f"user_nbf:{payload['sub']}")
|
||||||
|
if nbf_bytes is not None:
|
||||||
|
# Tolerate both bytes and str returns (FakeRedis in tests may return
|
||||||
|
# either depending on what was stored; real aioredis returns bytes).
|
||||||
|
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)
|
||||||
|
# ── end user_nbf check ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_uuid = uuid.UUID(payload["sub"])
|
user_uuid = uuid.UUID(payload["sub"])
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ def create_access_token(user_id: str, role: str) -> str:
|
|||||||
"typ": "access",
|
"typ": "access",
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
"jti": str(uuid.uuid4()),
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from sqlalchemy import select
|
|||||||
from deps.auth import get_current_admin
|
from deps.auth import get_current_admin
|
||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
from services.auth import hash_password
|
from services.auth import hash_password
|
||||||
|
from tests.test_auth_api import FakeRedis
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -70,17 +71,26 @@ async def make_regular_user(session: AsyncSession) -> User:
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def admin_client(db_session: AsyncSession):
|
async def admin_client(db_session: AsyncSession):
|
||||||
"""Async client with get_current_admin overridden to an admin user."""
|
"""Async client with get_current_admin overridden to an admin user.
|
||||||
|
|
||||||
|
Phase 7.2: app.state.redis is set to a FakeRedis() instance so that
|
||||||
|
the admin deactivation handler (post-Wave 2) can call
|
||||||
|
request.app.state.redis.set(...) without AttributeError.
|
||||||
|
"""
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
admin = await make_admin_user(db_session)
|
admin = await make_admin_user(db_session)
|
||||||
app.dependency_overrides[get_db] = lambda: db_session
|
app.dependency_overrides[get_db] = lambda: db_session
|
||||||
app.dependency_overrides[get_current_admin] = lambda: admin
|
app.dependency_overrides[get_current_admin] = lambda: admin
|
||||||
|
# Phase 7.2: mount FakeRedis for deactivation handler NBF write
|
||||||
|
app.state.redis = FakeRedis()
|
||||||
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
yield c, admin, db_session
|
yield c, admin, db_session
|
||||||
|
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
# Reset redis to avoid leaking state between test files (mirrors authed_client teardown)
|
||||||
|
app.state.redis = None
|
||||||
|
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -429,3 +439,65 @@ async def test_delete_user_no_body(admin_client):
|
|||||||
|
|
||||||
resp = await client.delete(f"/api/admin/users/{target.id}")
|
resp = await client.delete(f"/api/admin/users/{target.id}")
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 7.2 Wave 0 stubs: NBF-write on admin deactivation ──────────────────
|
||||||
|
# These two tests are xfailed with strict=False. When Wave 2 (Plan 03) adds
|
||||||
|
# user_nbf write to the admin deactivation handler, these stubs are promoted
|
||||||
|
# by replacing `assert False, "stub"` with real Redis key assertions.
|
||||||
|
#
|
||||||
|
# Invariant preserved in Wave 2: activation (is_active=True) must NOT write
|
||||||
|
# user_nbf — only deactivation writes it (RESEARCH.md anti-pattern).
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deactivate_user_writes_user_nbf_to_redis(admin_client):
|
||||||
|
"""PATCH /api/admin/users/{id}/status {is_active: false} must write user_nbf:{id} to Redis."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
client, _admin, session = admin_client
|
||||||
|
target = await make_regular_user(session)
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/admin/users/{target.id}/status",
|
||||||
|
json={"is_active": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{target.id}")
|
||||||
|
assert nbf_bytes is not None
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_user_does_not_write_user_nbf(admin_client):
|
||||||
|
"""PATCH /api/admin/users/{id}/status {is_active: true} must NOT write user_nbf:{id} to Redis.
|
||||||
|
|
||||||
|
Anti-pattern from RESEARCH.md: do not write user_nbf for successful activation.
|
||||||
|
This negative-guard test ensures Wave 2 only writes on deactivation, not activation.
|
||||||
|
"""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
client, _admin, session = admin_client
|
||||||
|
target = await make_regular_user(session)
|
||||||
|
|
||||||
|
# Deactivate first (sets is_active=False)
|
||||||
|
await client.patch(
|
||||||
|
f"/api/admin/users/{target.id}/status",
|
||||||
|
json={"is_active": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear any nbf that may have been set by deactivation
|
||||||
|
fake_redis = app.state.redis
|
||||||
|
if hasattr(fake_redis, "_store"):
|
||||||
|
fake_redis._store.pop(f"user_nbf:{target.id}", None)
|
||||||
|
|
||||||
|
# Now activate (is_active=True) — must NOT set user_nbf
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/admin/users/{target.id}/status",
|
||||||
|
json={"is_active": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{target.id}")
|
||||||
|
assert nbf_bytes is None, "activation must NOT write user_nbf (anti-pattern guard)"
|
||||||
|
|||||||
@@ -600,3 +600,121 @@ async def test_disable_totp_revokes_other_sessions(authed_client, db_session: As
|
|||||||
)
|
)
|
||||||
rows = result2.scalars().all()
|
rows = result2.scalars().all()
|
||||||
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
|
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 7.2 Wave 0 stubs: NBF-write on security events ─────────────────────
|
||||||
|
# These three tests are xfailed with strict=False. When Wave 2 (Plan 03) adds
|
||||||
|
# user_nbf writes to the handlers, these stubs are promoted to real assertions
|
||||||
|
# by replacing `pytest.xfail(...)` with actual Redis key assertions.
|
||||||
|
#
|
||||||
|
# Implementation hint for Wave 2 (kept here for context):
|
||||||
|
# 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}")
|
||||||
|
# assert nbf_bytes is not None
|
||||||
|
# assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_change_password_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
|
||||||
|
"""POST /api/auth/change-password must write user_nbf:{user_id} to Redis."""
|
||||||
|
from main import app
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
|
await _register(authed_client, handle="nbfw_cp1", email="nbfw_cp1@example.com")
|
||||||
|
login_resp = await _login(authed_client, email="nbfw_cp1@example.com")
|
||||||
|
token = login_resp.json()["access_token"]
|
||||||
|
|
||||||
|
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_cp1@example.com"))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
with patch("services.auth.check_hibp", return_value=False):
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
|
||||||
|
assert nbf_bytes is not None
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
|
||||||
|
"""POST /api/auth/totp/enable must write user_nbf:{user_id} to Redis."""
|
||||||
|
from main import app
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
|
await _register(authed_client, handle="nbfw_et1", email="nbfw_et1@example.com")
|
||||||
|
login_resp = await _login(authed_client, email="nbfw_et1@example.com")
|
||||||
|
token = login_resp.json()["access_token"]
|
||||||
|
|
||||||
|
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_et1@example.com"))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with patch("services.auth.verify_totp", return_value=True):
|
||||||
|
with patch("services.auth.store_backup_codes", return_value=None):
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/auth/totp/enable",
|
||||||
|
json={"code": "123456"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
|
||||||
|
assert nbf_bytes is not None
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
|
||||||
|
"""DELETE /api/auth/totp must write user_nbf:{user_id} to Redis."""
|
||||||
|
from main import app
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
|
await _register(authed_client, handle="nbfw_dt1", email="nbfw_dt1@example.com")
|
||||||
|
login_resp = await _login(authed_client, email="nbfw_dt1@example.com")
|
||||||
|
token = login_resp.json()["access_token"]
|
||||||
|
|
||||||
|
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_dt1@example.com"))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user.totp_enabled = True
|
||||||
|
user.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await authed_client.delete(
|
||||||
|
"/api/auth/totp",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
|
||||||
|
assert nbf_bytes is not None
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_password_reset_confirm_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
|
||||||
|
"""POST /api/auth/password-reset/confirm must write user_nbf:{user_id} to Redis (CR-02 gap closure)."""
|
||||||
|
from main import app
|
||||||
|
from services.auth import create_password_reset_token
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
|
await _register(authed_client, handle="nbfw_prc1", email="nbfw_prc1@example.com")
|
||||||
|
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_prc1@example.com"))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
reset_token = create_password_reset_token(str(user.id))
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/auth/password-reset/confirm",
|
||||||
|
json={"token": reset_token, "new_password": "NewSecure!Pass99#"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
|
||||||
|
assert nbf_bytes is not None
|
||||||
|
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ Tests verify:
|
|||||||
- get_current_user raises HTTP 401 when user.is_active is False
|
- get_current_user raises HTTP 401 when user.is_active is False
|
||||||
- get_current_admin raises HTTP 403 when user.role == "user"
|
- get_current_admin raises HTTP 403 when user.role == "user"
|
||||||
- get_current_admin returns user when user.role == "admin"
|
- get_current_admin returns user when user.role == "admin"
|
||||||
|
|
||||||
|
Phase 7.2 additions (Wave 0 scaffolding):
|
||||||
|
- FakeRedis attached to app.state.redis so Wave 1 NBF check in get_current_user
|
||||||
|
does not raise AttributeError: 'State' object has no attribute 'redis'
|
||||||
|
- Three xfail stubs for NBF check behaviours (reject, allow, fail-open)
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -16,11 +21,18 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from tests.test_auth_api import FakeRedis
|
||||||
|
|
||||||
|
|
||||||
# ── Minimal test app with /test/me and /test/admin routes ─────────────────────
|
# ── Minimal test app with /test/me and /test/admin routes ─────────────────────
|
||||||
|
|
||||||
def make_test_app():
|
def make_test_app():
|
||||||
"""Create a minimal FastAPI app that exercises the auth deps."""
|
"""Create a minimal FastAPI app that exercises the auth deps.
|
||||||
|
|
||||||
|
Phase 7.2: app.state.redis is set to a FakeRedis() instance so that the
|
||||||
|
NBF check in get_current_user (Wave 1) can call request.app.state.redis
|
||||||
|
without raising AttributeError (Pitfall 4 from Phase 7.2 RESEARCH.md).
|
||||||
|
"""
|
||||||
from deps.auth import get_current_user, get_current_admin
|
from deps.auth import get_current_user, get_current_admin
|
||||||
from db.models import User
|
from db.models import User
|
||||||
|
|
||||||
@@ -34,12 +46,20 @@ def make_test_app():
|
|||||||
async def admin_only(_admin: User = Depends(get_current_admin)):
|
async def admin_only(_admin: User = Depends(get_current_admin)):
|
||||||
return {"role": _admin.role}
|
return {"role": _admin.role}
|
||||||
|
|
||||||
|
# Phase 7.2: mount FakeRedis so NBF check in get_current_user works
|
||||||
|
test_app.state.redis = FakeRedis()
|
||||||
|
|
||||||
return test_app
|
return test_app
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def auth_client(db_session: AsyncSession):
|
async def auth_client(db_session: AsyncSession):
|
||||||
"""Async HTTP test client for the auth-dep test app."""
|
"""Async HTTP test client for the auth-dep test app.
|
||||||
|
|
||||||
|
Phase 7.2: each invocation of make_test_app() creates a fresh FakeRedis
|
||||||
|
instance (instantiated inside make_test_app). The NBF check in
|
||||||
|
get_current_user reads request.app.state.redis — this fixture provides it.
|
||||||
|
"""
|
||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
|
|
||||||
app = make_test_app()
|
app = make_test_app()
|
||||||
@@ -158,3 +178,73 @@ def test_deps_auth_has_http_403():
|
|||||||
source = f.read()
|
source = f.read()
|
||||||
assert re.search(r"HTTP_403_FORBIDDEN", source), \
|
assert re.search(r"HTTP_403_FORBIDDEN", source), \
|
||||||
"deps/auth.py must raise HTTP 403 for non-admin access"
|
"deps/auth.py must raise HTTP 403 for non-admin access"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 7.2 Wave 0 stubs: NBF check behaviours ─────────────────────────────
|
||||||
|
# These three tests are xfailed with strict=False. When Wave 1 (Plan 02) adds
|
||||||
|
# the NBF check to get_current_user, the stubs are promoted to real assertions
|
||||||
|
# by replacing `assert False, "stub"` with the actual assertion logic.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_user_rejects_token_when_iat_before_user_nbf(auth_client, db_session):
|
||||||
|
"""Token with iat < user_nbf in Redis should return 401 'Session invalidated'."""
|
||||||
|
from services.auth import create_access_token
|
||||||
|
import time
|
||||||
|
|
||||||
|
user = await _create_user(db_session, role="user")
|
||||||
|
token = create_access_token(str(user.id), "user")
|
||||||
|
|
||||||
|
# Pre-populate Redis with a future nbf (token's iat will be < this value)
|
||||||
|
future_ts = int(time.time()) + 3600
|
||||||
|
fake_redis = auth_client._transport.app.state.redis
|
||||||
|
await fake_redis.set(f"user_nbf:{user.id}", str(future_ts).encode())
|
||||||
|
|
||||||
|
resp = await auth_client.get(
|
||||||
|
"/test/me", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["detail"] == "Session invalidated"
|
||||||
|
assert resp.headers.get("WWW-Authenticate") == "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_user_allows_token_when_iat_after_user_nbf(auth_client, db_session):
|
||||||
|
"""Token with iat > user_nbf in Redis should return 200 (token is valid)."""
|
||||||
|
from services.auth import create_access_token
|
||||||
|
import time
|
||||||
|
|
||||||
|
user = await _create_user(db_session, role="user")
|
||||||
|
token = create_access_token(str(user.id), "user")
|
||||||
|
|
||||||
|
# Pre-populate Redis with a past nbf (token's iat will be > this value)
|
||||||
|
past_ts = int(time.time()) - 3600
|
||||||
|
fake_redis = auth_client._transport.app.state.redis
|
||||||
|
await fake_redis.set(f"user_nbf:{user.id}", str(past_ts).encode())
|
||||||
|
|
||||||
|
resp = await auth_client.get(
|
||||||
|
"/test/me", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_user_failopen_on_redis_error(auth_client, db_session):
|
||||||
|
"""Redis error during NBF check must fail-open (return 200, not crash)."""
|
||||||
|
from services.auth import create_access_token
|
||||||
|
|
||||||
|
class _BrokenRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
raise RuntimeError("simulated redis down")
|
||||||
|
|
||||||
|
user = await _create_user(db_session, role="user")
|
||||||
|
token = create_access_token(str(user.id), "user")
|
||||||
|
|
||||||
|
# Override app.state.redis with a broken redis for this test
|
||||||
|
auth_client._transport.app.state.redis = _BrokenRedis()
|
||||||
|
|
||||||
|
resp = await auth_client.get(
|
||||||
|
"/test/me", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
# D-04: Redis outage must fail-open; request proceeds normally
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|||||||
@@ -194,6 +194,26 @@ async def test_rotate_revoked_token_raises(db_session):
|
|||||||
await rotate_refresh_token(db_session, raw)
|
await rotate_refresh_token(db_session, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_access_token_includes_jti_claim():
|
||||||
|
"""create_access_token payload must include a 'jti' key with a UUID-format string value."""
|
||||||
|
import uuid
|
||||||
|
from services.auth import create_access_token, decode_access_token
|
||||||
|
t = create_access_token("test-uid", "user")
|
||||||
|
decoded = decode_access_token(t)
|
||||||
|
assert "jti" in decoded, "jti claim missing from access token payload"
|
||||||
|
assert isinstance(decoded["jti"], str), "jti claim must be a string"
|
||||||
|
uuid.UUID(decoded["jti"]) # raises ValueError if not a valid UUID
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_access_token_jti_is_unique_per_call():
|
||||||
|
"""Two consecutive create_access_token calls must produce different jti values."""
|
||||||
|
import uuid
|
||||||
|
from services.auth import create_access_token, decode_access_token
|
||||||
|
t1 = decode_access_token(create_access_token("test-uid", "user"))
|
||||||
|
t2 = decode_access_token(create_access_token("test-uid", "user"))
|
||||||
|
assert t1["jti"] != t2["jti"], "jti values must differ between calls"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_store_and_verify_backup_codes(db_session):
|
async def test_store_and_verify_backup_codes(db_session):
|
||||||
"""store_backup_codes inserts rows; verify_backup_code matches correct code."""
|
"""store_backup_codes inserts rows; verify_backup_code matches correct code."""
|
||||||
|
|||||||
Reference in New Issue
Block a user