Compare commits

...
4 Commits
Author SHA1 Message Date
curo1305andClaude Sonnet 4.6 22fcb53d9d docs(07.4): create phase plan — token fingerprinting / token binding
2 plans (2 waves): Wave 0 xfail stubs for test_auth_fgp.py (4 FGP tests),
Wave 1 production implementation (_compute_fgp helper, create_access_token
fgp claim, get_current_user fgp validation, login/refresh call-site updates,
test promotion) + version bump to 0.1.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:44:21 +02:00
curo1305andClaude Sonnet 4.6 de0f3fb7c3 docs(07.4): create phase plan — 2 plans, Wave 0 xfail stubs + Wave 1 fgp implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:22:29 +02:00
curo1305 50668cc916 docs(state): record phase 7.4 context session 2026-06-06 19:42:13 +02:00
curo1305 78316b6ed7 docs(07.4): capture phase context 2026-06-06 19:42:08 +02:00
9 changed files with 1649 additions and 11 deletions
+10 -2
View File
@@ -506,7 +506,15 @@ Before any phase is marked complete, all three gates must pass:
**Depends on**: Phase 7.3 **Depends on**: Phase 7.3
**Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding" **Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding"
**Status:** Not planned yet **Plans**: 2 plans
**Wave 0** — Test scaffolds (no production code)
- [ ] 07.4-01-PLAN.md — Wave 0 xfail stubs: test_auth_fgp.py (4 stubs covering FGP-01..04)
**Wave 1** *(blocked on Wave 0)* — Production implementation + test promotion
- [ ] 07.4-02-PLAN.md — _compute_fgp helper + create_access_token fgp claim + get_current_user fgp validation + login/refresh call site updates + promote all 4 FGP tests
--- ---
@@ -526,4 +534,4 @@ Before any phase is marked complete, all three gates must pass:
| 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 | 3/3 | Complete | 2026-06-05 | | 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 |
| 7.3. Security: ES256 algorithm upgrade | 3/3 | Complete | 2026-06-06 | | 7.3. Security: ES256 algorithm upgrade | 3/3 | Complete | 2026-06-06 |
| 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — | | 7.4. Security: token fingerprinting / token binding | 0/2 | Planned | — |
+9 -9
View File
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: "audit gaps: SHARE-02/STORE-06/ADMIN-06" milestone_name: "audit gaps: SHARE-02/STORE-06/ADMIN-06"
current_phase: 07.4 current_phase: 07.4
status: planning status: completed
last_updated: "2026-06-06T17:45:00.000Z" last_updated: "2026-06-06T17:42:13.466Z"
progress: progress:
total_phases: 13 total_phases: 7
completed_phases: 12 completed_phases: 6
total_plans: 63 total_plans: 20
completed_plans: 63 completed_plans: 20
percent: 92 percent: 86
--- ---
# Project State # Project State
@@ -36,7 +36,7 @@ progress:
| 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 | ✓ Complete (3/3 plans, 9/9 verified, 84/84 tests) | | 7.2 | Security: JTI claim + Redis access-token revocation | ✓ Complete (3/3 plans, 9/9 verified, 84/84 tests) |
| 7.3 | Security: ES256 algorithm upgrade | ✓ Complete (3/3 plans, all 9 tests passing, remember_me shipped) | | 7.3 | Security: ES256 algorithm upgrade | ✓ Complete (3/3 plans, all 9 tests passing, remember_me shipped) |
| 7.4 | Security: Token fingerprinting / token binding | ◆ Not planned yet | | 7.4 | Security: Token fingerprinting / token binding | ◆ Planned (2/2 plans, ready to execute) |
## Current Position ## Current Position
@@ -210,6 +210,6 @@ _Updated at each phase transition._
| 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 |
| 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 | | 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 |
| Last session | 2026-06-06 — Phase 7.3 complete: ES256 asymmetric JWT signing live; default session 16h; remember_me opt-in 30d; startup bulk-revocation hook; all 9 ES256/remember-me tests green; v0.1.2 | | Last session | 2026-06-06 — Phase 7.3 complete: ES256 asymmetric JWT signing live; default session 16h; remember_me opt-in 30d; startup bulk-revocation hook; all 9 ES256/remember-me tests green; v0.1.2 |
| Next action | Plan Phase 7.4: /gsd:plan-phase 7.4 (token fingerprinting) | | Next action | Execute Phase 7.4: /gsd:execute-phase 7.4 (token fingerprinting) |
| Pending decisions | None | | Pending decisions | None |
| Resume file | None | | Resume file | None |
@@ -0,0 +1,188 @@
---
phase: "07.4"
plan: "01"
type: execute
wave: 0
depends_on: []
files_modified:
- backend/tests/test_auth_fgp.py
autonomous: true
requirements:
- FGP-CONCERN # tracked in .planning/codebase/CONCERNS.md §"No Token Fingerprint / Token Binding"
must_haves:
truths:
- "test_auth_fgp.py exists with exactly 4 test functions"
- "All 4 tests are decorated with @pytest.mark.xfail(strict=False, reason='not implemented yet')"
- "pytest -v reports all 4 as XFAIL — no failures, no errors"
- "No production source files are modified"
artifacts:
- path: "backend/tests/test_auth_fgp.py"
provides: "Wave 0 xfail stubs for FGP-01..FGP-04"
contains: "test_fgp_match_returns_200"
key_links:
- from: "backend/tests/test_auth_fgp.py"
to: "backend/tests/test_auth_deps.py"
via: "imports FakeRedis, copies make_test_app / auth_client / _create_user patterns"
pattern: "from tests.test_auth_api import FakeRedis"
---
<objective>
Create the Wave 0 test scaffold for Phase 7.4: a new test file `backend/tests/test_auth_fgp.py` containing 4 xfail stubs covering the four fingerprint behaviours (FGP-01..FGP-04). No production code is touched in this plan.
Purpose: Establishes the Nyquist test harness before any production code changes. Follows the xfail(strict=False) Wave 0 convention established in Phases 7.2 and 7.3.
Output: `backend/tests/test_auth_fgp.py` with 4 stubs; full test suite still passes with zero failures.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md
<interfaces>
<!-- Key patterns the executor needs. Extracted from codebase. -->
From backend/tests/test_auth_deps.py (full harness to copy):
Imports:
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tests.test_auth_api import FakeRedis
make_test_app():
Creates minimal FastAPI app with /test/me route wired to get_current_user.
Sets app.state.redis = FakeRedis() so NBF check and fgp check can access request.app.state.redis.
auth_client fixture:
@pytest_asyncio.fixture
async def auth_client(db_session: AsyncSession):
app = make_test_app()
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
_create_user helper (no decorator — plain async function):
Inserts minimal User row; returns User ORM object.
Fields: id=uuid.uuid4(), handle, email, password_hash, role, is_active=True
Test function pattern:
@pytest.mark.asyncio
async def test_name(auth_client, db_session):
from services.auth import create_access_token
user = await _create_user(db_session)
token = create_access_token(str(user.id), "user")
resp = await auth_client.get("/test/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
conftest _patch_es256_test_keys is autouse=True at session scope — no explicit reference needed in this file.
From backend/tests/test_auth_es256.py (xfail stub pattern for Phase 7.3):
@pytest.mark.xfail(strict=False, reason="not implemented yet")
@pytest.mark.asyncio
async def test_stub_name(...):
pytest.xfail("not implemented yet")
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create test_auth_fgp.py with 4 xfail stubs (FGP-01..FGP-04)</name>
<files>backend/tests/test_auth_fgp.py</files>
<read_first>
- backend/tests/test_auth_deps.py — copy the full harness (make_test_app, auth_client fixture, _create_user helper, imports block). This is the structural template; copy verbatim then modify test functions only.
- backend/tests/test_auth_es256.py — xfail stub decoration pattern used in Phase 7.3. Stubs use @pytest.mark.xfail(strict=False, reason="not implemented yet") and body is only pytest.xfail("not implemented yet").
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md §"Test Case Structure" — exact test names and behaviour descriptions for all 4 tests.
</read_first>
<behavior>
- FGP-01 test_fgp_match_returns_200: issues token with user_agent="Mozilla/5.0" accept_lang="en", sends request with matching headers — expects 200
- FGP-02 test_fgp_mismatch_returns_401: issues token with user_agent="Mozilla/5.0" accept_lang="en", sends request with user_agent="different-agent" — expects 401 and detail="Token fingerprint mismatch"
- FGP-03 test_no_fgp_claim_allowed: crafts token manually without "fgp" key in payload — expects 200 (migration grace)
- FGP-04 test_missing_headers_empty_string_binding: issues token with no user_agent/accept_lang args (defaults to ""), sends request with no User-Agent/Accept-Language headers — expects 200
</behavior>
<action>
Create backend/tests/test_auth_fgp.py. Structure:
1. Module docstring explaining Phase 7.4 fgp test coverage (FGP-01..04).
2. Imports: copy the exact imports block from test_auth_deps.py. Add these additional imports needed for FGP-03 (manual token craft): `import base64`, `import time`, `import jwt as _jwt`, and `from config import settings`.
3. Copy make_test_app() verbatim from test_auth_deps.py (includes app.state.redis = FakeRedis()). Do NOT add a /test/admin route — not needed here.
4. Copy auth_client fixture verbatim from test_auth_deps.py.
5. Copy _create_user helper verbatim from test_auth_deps.py.
6. Write 4 test functions in this order:
- test_fgp_match_returns_200 (FGP-01)
- test_fgp_mismatch_returns_401 (FGP-02)
- test_no_fgp_claim_allowed (FGP-03)
- test_missing_headers_empty_string_binding (FGP-04)
Each test function:
- Decorated with @pytest.mark.xfail(strict=False, reason="not implemented yet") ABOVE @pytest.mark.asyncio
- Decorated with @pytest.mark.asyncio
- Accepts (auth_client, db_session) as parameters
- Body is a single line: pytest.xfail("not implemented yet")
Do NOT write any assertion logic, token construction, or request code inside the stubs. The stub body is ONLY the pytest.xfail() call. Implementation comes in Wave 1 (Plan 07.4-02).
7. No production files (services/auth.py, deps/auth.py, api/auth.py) may be modified in this task.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_auth_fgp.py -v 2>&1 | tail -20</automated>
</verify>
<done>
- backend/tests/test_auth_fgp.py exists
- pytest tests/test_auth_fgp.py -v shows exactly 4 tests, all reported as XFAIL
- Zero FAILED, zero ERROR entries in the output
- pytest -v (full suite) still passes with zero failures
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| test harness → test DB | xfail stubs create no DB state; no trust boundary crossed in Wave 0 |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07.4-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan; stdlib only |
</threat_model>
<verification>
After this plan completes:
- `pytest tests/test_auth_fgp.py -v` shows 4 XFAIL
- `pytest -v` (full suite) exits 0 with the same pass count as before this plan (411+) plus 4 new XFAIL
- No changes to backend/services/auth.py, backend/deps/auth.py, or backend/api/auth.py
</verification>
<success_criteria>
- `backend/tests/test_auth_fgp.py` exists with 4 test stubs (FGP-01..04)
- All 4 stubs use `@pytest.mark.xfail(strict=False, reason="not implemented yet")`
- Full test suite passes with zero failures; 4 new XFAIL added
- No production code modified
</success_criteria>
<output>
Create `.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,357 @@
---
phase: "07.4"
plan: "02"
type: execute
wave: 1
depends_on:
- "07.4-01"
files_modified:
- backend/services/auth.py
- backend/deps/auth.py
- backend/api/auth.py
- backend/tests/test_auth_fgp.py
- backend/main.py
- frontend/package.json
autonomous: true
requirements:
- FGP-CONCERN # tracked in .planning/codebase/CONCERNS.md §"No Token Fingerprint / Token Binding"
must_haves:
truths:
- "Every issued access token contains a 'fgp' claim (16-char hex)"
- "A request presenting a token whose fgp was computed from different headers receives HTTP 401 'Token fingerprint mismatch'"
- "A token without a 'fgp' claim is accepted (migration grace — old sessions not broken)"
- "Missing User-Agent / Accept-Language headers both default to empty string and still produce a valid, consistent fingerprint"
- "All 4 FGP tests in test_auth_fgp.py pass (promoted from xfail stubs)"
- "Full pytest suite passes with zero failures"
artifacts:
- path: "backend/services/auth.py"
provides: "_compute_fgp helper + extended create_access_token signature"
contains: "_compute_fgp"
- path: "backend/deps/auth.py"
provides: "fgp validation block after user_nbf check"
contains: "Token fingerprint mismatch"
- path: "backend/api/auth.py"
provides: "login + refresh call sites pass request headers"
contains: "user_agent=request.headers.get"
- path: "backend/tests/test_auth_fgp.py"
provides: "4 promoted tests (FGP-01..04) — all passing"
contains: "test_fgp_match_returns_200"
key_links:
- from: "backend/api/auth.py (login handler)"
to: "backend/services/auth.create_access_token"
via: "user_agent=request.headers.get('User-Agent',''), accept_lang=request.headers.get('Accept-Language','')"
pattern: "user_agent=request.headers.get"
- from: "backend/deps/auth.get_current_user"
to: "backend/services/auth._compute_fgp"
via: "auth_service._compute_fgp(...) + hmac.compare_digest"
pattern: "hmac.compare_digest\\(fgp_claim, fgp_actual\\)"
---
<objective>
Implement token fingerprinting end-to-end: add `_compute_fgp` to `services/auth.py`, embed the `fgp` claim in every issued access token, add the fgp validation block to `get_current_user` in `deps/auth.py`, update the two call sites in `api/auth.py`, and promote all 4 xfail stubs in `test_auth_fgp.py` to passing integration tests.
Purpose: Closes the "No Token Fingerprint / Token Binding" concern in CONCERNS.md. A stolen access token can only be replayed from the same User-Agent + Accept-Language context in which it was issued.
Output: Three production files modified (~27 lines total), one test file promoted (4 tests now passing).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md
@.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-SUMMARY.md
<interfaces>
<!-- Exact current state of each file being modified. Verified against codebase. -->
From backend/services/auth.py:
Existing imports at lines 18-39 — both `import hashlib` (line 21) and `import hmac` (line 22) already present.
Current create_access_token signature (line 87):
def create_access_token(user_id: str, role: str) -> str:
Existing payload dict (lines 93-99):
payload = {
"sub": str(user_id),
"role": role,
"typ": "access",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": str(uuid.uuid4()),
}
Existing hmac.compare_digest usage (line ~419):
if hmac.compare_digest(candidate_suffix.upper(), suffix):
From backend/deps/auth.py:
Existing imports (lines 23-33) — `hmac` NOT imported. Add `import hmac` here.
`from services import auth as auth_service` at line 32 — use auth_service._compute_fgp(...) for cross-module call.
get_current_user signature (lines 41-44) — request: Request already present.
End of user_nbf block (line 85):
# ── end user_nbf check ──────────────────────────────────────────────────────
Line 87 (first line AFTER the block):
try:
user_uuid = uuid.UUID(payload["sub"])
Insert fgp block BETWEEN line 85 and line 87.
From backend/api/auth.py:
Login call site (line 290):
access_token = auth_service.create_access_token(str(user.id), user.role)
Refresh call site (line 364):
access_token = auth_service.create_access_token(user_id_str, user.role)
Both handlers already have `request: Request` in their signature.
From backend/tests/test_auth_deps.py:
FakeRedis import: from tests.test_auth_api import FakeRedis
make_test_app() sets app.state.redis = FakeRedis()
_create_user(db_session, role="user", is_active=True) helper pattern
auth_client fixture uses ASGITransport + AsyncClient
For FGP-03 (manual token without fgp claim):
Import `import jwt as _jwt`, `import base64`, `import time`
Decode private key: base64.b64decode(settings.jwt_private_key).decode()
Encode: _jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
payload_no_fgp must contain sub, role, typ="access", iat (as datetime or int), exp, jti
Use datetime.now(timezone.utc) for iat, iat + timedelta(minutes=15) for exp, str(uuid.uuid4()) for jti
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add _compute_fgp and extend create_access_token in services/auth.py</name>
<files>backend/services/auth.py</files>
<read_first>
- backend/services/auth.py — read lines 80-105 to see the exact current signature of create_access_token, the payload dict, and the line immediately before it (the JWT helpers comment at line 85). Insert _compute_fgp between line 85 and line 87.
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md §D-04, D-05 — locked function signature and return value formula.
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md §"backend/services/auth.py" — exact code to insert.
</read_first>
<behavior>
- _compute_fgp("Mozilla/5.0", "en") returns a 16-char hex string
- _compute_fgp("", "") returns a deterministic 16-char hex string (not empty, not None)
- create_access_token(user_id, role) still works without new params (backward-compatible defaults)
- create_access_token(user_id, role, user_agent="X", accept_lang="Y") embeds fgp=_compute_fgp("X","Y") in payload
- JWT payload from create_access_token contains a "fgp" key
</behavior>
<action>
Make two targeted edits to backend/services/auth.py:
Edit 1 — Insert `_compute_fgp` helper function.
Insertion point: immediately before the `def create_access_token` line (currently line 87), after the `# ── JWT helpers ──...` comment.
Function body per D-04: `hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), hashlib.sha256).hexdigest()[:16]`
Add a one-line docstring: "Return 16-char hex fingerprint binding a token to its client context (D-04)."
No new imports are needed — `import hmac` and `import hashlib` already exist at lines 21-22.
Edit 2 — Extend create_access_token signature (D-05) and embed fgp claim in payload.
Change signature from `def create_access_token(user_id: str, role: str) -> str:` to:
`def create_access_token(user_id: str, role: str, user_agent: str = "", accept_lang: str = "") -> str:`
Add `"fgp": _compute_fgp(user_agent, accept_lang)` as a new key in the payload dict, alongside the existing sub/role/typ/iat/exp/jti keys.
Do NOT modify the return statement or any other logic in the function.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from services.auth import create_access_token, _compute_fgp; t = create_access_token('00000000-0000-0000-0000-000000000001', 'user', user_agent='test', accept_lang='en'); import jwt, base64; from config import settings; pub = base64.b64decode(settings.jwt_public_key).decode(); p = jwt.decode(t, pub, algorithms=['ES256'], options={'verify_exp':False}); assert 'fgp' in p and len(p['fgp']) == 16, f'fgp missing or wrong length: {p}'; print('OK:', p['fgp'])"</automated>
</verify>
<done>
- backend/services/auth.py contains `def _compute_fgp(user_agent: str, accept_lang: str) -> str:`
- backend/services/auth.py contains `"fgp": _compute_fgp(user_agent, accept_lang)` in create_access_token payload
- create_access_token signature now has `user_agent: str = ""` and `accept_lang: str = ""` parameters
- Python inline verify command prints "OK:" followed by a 16-char hex string
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add fgp validation block to get_current_user in deps/auth.py</name>
<files>backend/deps/auth.py</files>
<read_first>
- backend/deps/auth.py — read lines 23-35 (imports block) and lines 62-95 (user_nbf block + lines immediately after). The fgp block inserts AFTER the `# ── end user_nbf check ──` comment (currently line 85) and BEFORE the `try: user_uuid = uuid.UUID(payload["sub"])` block (currently line 87).
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md §"backend/deps/auth.py" — exact fgp block code, exact insertion point, note that `import hmac` must be added to the imports block.
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md §D-03, D-06 — mismatch raises 401 immediately; empty fgp_claim (old tokens) is allowed.
</read_first>
<behavior>
- Request with token whose fgp matches recomputed value: no exception raised, flow continues normally
- Request with token whose fgp does NOT match recomputed value: raises HTTPException(401, detail="Token fingerprint mismatch")
- Token without "fgp" key in payload (payload.get("fgp","") == ""): fgp block is skipped entirely, flow continues (migration grace per D-06)
- fgp comparison uses hmac.compare_digest (constant-time, per SEC-06)
</behavior>
<action>
Make two targeted edits to backend/deps/auth.py:
Edit 1 — Add `import hmac` to the imports block.
Insert `import hmac` after `import logging` and before `import uuid` (maintain alphabetical order within stdlib imports). Do not duplicate — verify `import hmac` is not already present before adding.
Edit 2 — Insert fgp validation block.
Insertion point: after the `# ── end user_nbf check ──────────────────────────────────────────────────────` comment line and before the next `try:` block (the one that does `user_uuid = uuid.UUID(payload["sub"])`).
The block to insert (per D-06 and RESEARCH.md Pattern 3):
# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
# Validates the fgp claim embedded by create_access_token. Empty claim means
# the token predates Phase 7.4 — allow gracefully (migration window, D-06).
# NOT wrapped in try/except: _compute_fgp is pure computation with no I/O.
fgp_claim = payload.get("fgp", "")
if fgp_claim:
fgp_actual = auth_service._compute_fgp(
request.headers.get("User-Agent", ""),
request.headers.get("Accept-Language", ""),
)
if not hmac.compare_digest(fgp_claim, fgp_actual):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
# ── end fgp check ───────────────────────────────────────────────────────────
Do NOT wrap this block in a try/except. Do NOT modify any other part of get_current_user.
The `auth_service._compute_fgp(...)` call uses the existing `from services import auth as auth_service` import at line 32 — no new import needed.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import ast, sys; src=open('deps/auth.py').read(); ast.parse(src); print('syntax OK')" && grep -c "Token fingerprint mismatch" deps/auth.py</automated>
</verify>
<done>
- backend/deps/auth.py contains `import hmac` in its imports block
- backend/deps/auth.py contains the string "Token fingerprint mismatch"
- backend/deps/auth.py contains `fgp_claim = payload.get("fgp", "")`
- backend/deps/auth.py contains `hmac.compare_digest(fgp_claim, fgp_actual)`
- Python syntax check passes (ast.parse exits 0)
- grep -c "Token fingerprint mismatch" deps/auth.py prints 1
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Update login + refresh call sites in api/auth.py; promote all 4 stubs to passing tests</name>
<files>backend/api/auth.py, backend/tests/test_auth_fgp.py</files>
<read_first>
- backend/api/auth.py — read lines 285-295 (login call site, current: `access_token = auth_service.create_access_token(str(user.id), user.role)`) and lines 360-370 (refresh call site, current: `access_token = auth_service.create_access_token(user_id_str, user.role)`). Both handlers already have `request: Request` in their signatures.
- backend/tests/test_auth_fgp.py — read the full file (created in Wave 0). Replace all 4 stub bodies (currently only `pytest.xfail("not implemented yet")`) with real test logic per RESEARCH.md §"Test Case Structure".
- .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md §"Test Case Structure" and §"Pattern 4: Caller updates in api/auth.py" — exact before/after forms for both call sites and exact test logic for all 4 tests.
- backend/tests/test_auth_deps.py — confirm the FGP-03 pattern for crafting a token without fgp claim (PyJWT direct encode with ES256, import jwt as _jwt, base64.b64decode settings.jwt_private_key).
</read_first>
<behavior>
api/auth.py:
- Login handler: create_access_token call passes user_agent=request.headers.get("User-Agent","") and accept_lang=request.headers.get("Accept-Language","")
- Refresh handler: same header passthrough
test_auth_fgp.py promoted tests:
- FGP-01 test_fgp_match_returns_200: token issued with user_agent="Mozilla/5.0" accept_lang="en"; request sent with same headers → 200
- FGP-02 test_fgp_mismatch_returns_401: token issued with user_agent="Mozilla/5.0" accept_lang="en"; request sent with user_agent="different-agent" → 401 and response JSON detail == "Token fingerprint mismatch"
- FGP-03 test_no_fgp_claim_allowed: token manually crafted with PyJWT (no "fgp" key in payload); request sent without extra headers → 200
- FGP-04 test_missing_headers_empty_string_binding: token issued with no user_agent/accept_lang args (empty defaults); request sent with no User-Agent or Accept-Language headers → 200
</behavior>
<action>
Edit 1 — backend/api/auth.py, login call site.
Find: `access_token = auth_service.create_access_token(str(user.id), user.role)`
Replace with:
access_token = auth_service.create_access_token(
str(user.id),
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
Edit 2 — backend/api/auth.py, refresh call site.
Find: `access_token = auth_service.create_access_token(user_id_str, user.role)`
Replace with:
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
Edit 3 — backend/tests/test_auth_fgp.py: add required imports and replace stub bodies.
Edit 4 — Version bump (per CLAUDE.md version bump rule: patch increment for plans shipping user-facing auth changes).
In backend/main.py: change `version="0.1.2"` to `version="0.1.3"`.
In frontend/package.json: change `"version": "0.1.2"` to `"version": "0.1.3"`.
Add to the imports block (if not already present from Wave 0):
import base64
import uuid as _uuid
import jwt as _jwt
from datetime import datetime, timezone, timedelta
from config import settings
For FGP-01 (test_fgp_match_returns_200):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with user_agent="Mozilla/5.0" and accept_lang="en", send GET /test/me with headers Authorization + User-Agent: Mozilla/5.0 + Accept-Language: en, assert status_code == 200.
For FGP-02 (test_fgp_mismatch_returns_401):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with user_agent="Mozilla/5.0" and accept_lang="en", send GET /test/me with Authorization + User-Agent: different-agent + Accept-Language: en, assert status_code == 401, assert response.json()["detail"] == "Token fingerprint mismatch".
For FGP-03 (test_no_fgp_claim_allowed):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, manually craft JWT payload dict with keys sub=str(user.id), role="user", typ="access", iat=datetime.now(timezone.utc), exp=datetime.now(timezone.utc)+timedelta(minutes=15), jti=str(_uuid.uuid4()) — do NOT include "fgp" key. Encode using _jwt.encode(payload_no_fgp, base64.b64decode(settings.jwt_private_key).decode(), algorithm="ES256"). Send GET /test/me with only Authorization header. Assert status_code == 200.
For FGP-04 (test_missing_headers_empty_string_binding):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with no user_agent/accept_lang args (defaults to ""), send GET /test/me with only Authorization header (no User-Agent, no Accept-Language), assert status_code == 200.
All four tests import create_access_token inline: `from services.auth import create_access_token`
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_auth_fgp.py -v && pytest tests/test_auth_api.py tests/test_auth_deps.py -x -q</automated>
</verify>
<done>
- pytest tests/test_auth_fgp.py -v shows 4 PASSED (0 xfail, 0 failed)
- pytest tests/test_auth_api.py tests/test_auth_deps.py -x -q passes (existing auth tests unbroken)
- backend/api/auth.py login call site contains `user_agent=request.headers.get("User-Agent", "")`
- backend/api/auth.py refresh call site contains `accept_lang=request.headers.get("Accept-Language", "")`
- Full suite: cd backend && pytest -v exits 0 with zero failures
- backend/main.py version string incremented to 0.1.3
- frontend/package.json version field incremented to 0.1.3
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (login/refresh) | Client headers are untrusted input; HMAC treats them as opaque bytes — no injection vector |
| JWT payload → get_current_user | fgp claim extracted from decoded (signature-verified) JWT; attacker cannot forge without ES256 private key |
| fgp comparison | Fixed-length (16-char hex) strings compared with hmac.compare_digest — timing-safe |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07.4-01 | Spoofing | access token replay from different device | mitigate | fgp mismatch → HTTP 401; attacker must match UA + Accept-Language from original context |
| T-07.4-02 | Information Disclosure | timing attack on fingerprint comparison | mitigate | `hmac.compare_digest` (constant-time) — SEC-06 requirement; already established in codebase |
| T-07.4-03 | Spoofing | empty-string collision (CLI clients share one fingerprint) | accept | D-02 deliberate design decision: CLI tools with no UA headers bind to fgp("","") and are internally consistent; equivalent to pre-7.4 security for those clients |
| T-07.4-04 | Tampering | fgp HTTPException swallowed by broad except | mitigate | fgp block placed OUTSIDE try/except (pure computation, no I/O); not wrapped in broad except so 401 always propagates |
| T-07.4-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed; stdlib hmac/hashlib only |
</threat_model>
<verification>
After this plan completes:
1. `pytest tests/test_auth_fgp.py -v` — 4 PASSED
2. `pytest -v` — full suite exits 0 with zero failures; total count increases by 4 (previously xfail stubs are now passing tests)
3. `grep -n "fgp" backend/services/auth.py` — shows _compute_fgp definition and payload["fgp"] insertion
4. `grep -n "Token fingerprint mismatch" backend/deps/auth.py` — shows 1 match
5. `grep -n "user_agent=request.headers" backend/api/auth.py` — shows 2 matches (login + refresh)
6. Security gate: `bandit -r backend/` exits with zero HIGH findings
</verification>
<success_criteria>
- _compute_fgp helper exists in backend/services/auth.py with correct HMAC-SHA256 formula (per D-04)
- create_access_token embeds "fgp" claim in every issued JWT (per D-05)
- get_current_user validates fgp with hmac.compare_digest; empty claim allowed; mismatch raises 401 "Token fingerprint mismatch" (per D-06)
- Login and refresh call sites in api/auth.py pass User-Agent and Accept-Language headers (per D-05)
- All 4 FGP tests pass: FGP-01 (match→200), FGP-02 (mismatch→401), FGP-03 (no claim→200), FGP-04 (missing headers→200)
- Full pytest suite exits 0 with zero failures
- backend/main.py and frontend/package.json both show version 0.1.3
</success_criteria>
<output>
Create `.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,106 @@
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Context
**Gathered:** 2026-06-06
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 7.4 adds a `fgp` (fingerprint) claim to every issued access token. The claim is a 16-char hex prefix of `HMAC-SHA256(SECRET_KEY, User-Agent + Accept-Language)`. In `get_current_user`, the fingerprint is recomputed from the incoming request headers and compared with `hmac.compare_digest`. A mismatch results in HTTP 401.
This limits the replay window of a stolen access token to the original device/browser context. No schema migrations, no new endpoints, no frontend changes.
</domain>
<decisions>
## Implementation Decisions
### HMAC Key
- **D-01:** Use `settings.secret_key` (`SECRET_KEY` env var) as the HMAC key. This was explicitly reserved for Phase 7.4 fingerprinting in Phase 7.3 D-03. No new env var is needed.
### Missing Header Behavior
- **D-02:** When `User-Agent` or `Accept-Language` is absent, use an empty string (`""`) as the fallback value for that header. The `fgp` claim is **always** computed and always validated — there is no "skip" path. This means CLI tools, Postman, and API clients receive tokens that bind to `fgp("", "")` or similar, and their requests continue to work as long as they consistently send the same (possibly absent) headers.
### Mismatch Enforcement
- **D-03:** A fingerprint mismatch raises HTTP 401 immediately with detail `"Token fingerprint mismatch"`. No soft/log-only mode. The protection is meaningless unless enforced.
### Fingerprint Computation Function
- **D-04:** Define a module-level helper `_compute_fgp(user_agent: str, accept_lang: str) -> str` in `backend/services/auth.py`. Returns `hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16]`. Centralises the logic; both `create_access_token` and `get_current_user` call the same function.
### create_access_token Signature Change
- **D-05:** `create_access_token(user_id, role)` gains two new parameters: `user_agent: str = ""` and `accept_lang: str = ""`. All callers pass request headers through. The default empty string means the function signature is backward-compatible with any test that doesn't yet pass headers.
### Validation in get_current_user
- **D-06:** After the user_nbf Redis check (Phase 7.2), add the fgp validation block. Extract `fgp_claim = payload.get("fgp", "")`. Recompute `fgp_actual = _compute_fgp(request.headers.get("User-Agent", ""), request.headers.get("Accept-Language", ""))`. If `fgp_claim` is non-empty and `not hmac.compare_digest(fgp_claim, fgp_actual)` → raise HTTP 401. If `fgp_claim` is empty (tokens issued before this phase), allow the request — graceful migration window.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Token issuance — target function
- `backend/services/auth.py` line 87 — `create_access_token(user_id, role)`: add `user_agent=""` and `accept_lang=""` params; compute `fgp` claim here using `_compute_fgp`
- `backend/services/auth.py` line 22 — `import hmac` already present; add `import hashlib` if not already there (needed for `sha256` digestmod)
### Token validation — target function
- `backend/deps/auth.py` line 41 — `get_current_user`: add fgp validation block after the `user_nbf` check (line 86); uses `request.headers.get(...)` (request already in signature)
### Callers of create_access_token (must be updated to pass headers)
- `backend/api/auth.py` — login handler (issues new access token after credential check)
- `backend/api/auth.py` — refresh handler (issues new access token when rotating refresh token)
- Any other site that calls `create_access_token` — grep for `create_access_token(` to find all callers
### Config — HMAC key
- `backend/config.py` line 31 — `secret_key: str = "CHANGEME"` — this is `settings.secret_key`; no new field needed
### CLAUDE.md security requirement
- `CLAUDE.md` §"Login token hardening" — mandates `fgp` claim = HMAC of `User-Agent + Accept-Language`, validated on every request
- `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding" — original risk description and fix approach
### Phase 7.2 pattern (user_nbf check — structural reference for placement)
- `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md` — fgp check must be placed AFTER the user_nbf block; follow the same fail-pattern (HTTPException guard + broad except)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `hmac` module already imported in `services/auth.py:22` — add `hashlib` import for SHA-256 digestmod
- `request.headers.get("User-Agent", "")` pattern is idiomatic FastAPI; `request: Request` is already in `get_current_user`'s signature
- `hmac.compare_digest` already used in `services/auth.py:419` for backup code comparison — same pattern applies here
### Established Patterns
- `user_nbf` check in `deps/auth.py:6285` — exact structural pattern to copy: `try / except HTTPException: raise / except Exception as exc: _logger.warning(...)`. Note: fgp validation does NOT use fail-open (unlike NBF) — it should raise inside the try block unconditionally on mismatch, not be swallowed by a broad except.
- `create_access_token` payload dict in `services/auth.py:93` — add `"fgp": fgp_value` alongside existing `sub`, `role`, `typ`, `iat`, `exp`, `jti` claims
### Integration Points
- `backend/api/auth.py` login handler — currently calls `create_access_token(str(user.id), user.role)`; update to pass `request.headers.get("User-Agent", "")` and `request.headers.get("Accept-Language", "")`. The login handler already receives `request: Request`.
- `backend/api/auth.py` refresh handler — same update required; the refresh endpoint also has `request: Request`
- `backend/deps/auth.py:86` — fgp check inserts right after the `user_nbf` block ends, before the `uuid.UUID(payload["sub"])` parse
</code_context>
<specifics>
## Specific Ideas
- Helper function signature: `def _compute_fgp(user_agent: str, accept_lang: str) -> str` — module-private, defined once, called from both `create_access_token` and `get_current_user`'s validation block via the auth service.
- The `fgp` check in `get_current_user` should be structured so that **tokens without an `fgp` claim are allowed** (graceful forward migration: existing logged-in sessions issued before this phase don't instantly break). Only tokens that carry an `fgp` claim get it validated.
- Test coverage must include: (1) token with correct fgp → 200, (2) token with wrong fgp → 401, (3) token without fgp claim → 200 (migration grace), (4) missing User-Agent → empty-string binding works.
</specifics>
<deferred>
## Deferred Ideas
- **Key rotation for SECRET_KEY**: A production key-rotation process for `SECRET_KEY` would invalidate all fgp bindings and refresh tokens simultaneously. Worth documenting in a RUNBOOK but not in scope here.
- **Per-request fingerprint rotation** (device key pinning, stronger binding): Future enhancement — not needed for v1.
</deferred>
---
*Phase: 07.4-security-token-fingerprinting-token-binding-inserted*
*Context gathered: 2026-06-06*
@@ -0,0 +1,59 @@
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-06
**Phase:** 07.4-security-token-fingerprinting-token-binding-inserted
**Areas discussed:** HMAC key source, Missing-header behavior, Mismatch enforcement
---
## HMAC Key Source
| Option | Description | Selected |
|--------|-------------|----------|
| SECRET_KEY | Already in docker-compose; Phase 7.3 D-03 explicitly reserved it for this use. No new env var needed. | ✓ |
| New FGP_HMAC_KEY env var | Dedicated key — key-separation principle. Requires adding to config.py, docker-compose.yml, .env, and README. | |
**User's choice:** SECRET_KEY
**Notes:** The prior-phase decision (7.3 D-03) already pointed here; confirmed.
---
## Missing-Header Behavior
| Option | Description | Selected |
|--------|-------------|----------|
| Empty string fallback | Use "" for any absent header; fgp always computed and validated. CLI tools and Postman work fine, binding to fgp("",""). | ✓ |
| Skip fingerprint check entirely | Omit fgp claim when headers absent; skip validation when claim absent. Weakens protection. | |
| Reject at issuance (401) | Login fails if User-Agent missing. Strongest binding but breaks all non-browser clients. | |
**User's choice:** Empty string fallback
**Notes:** Graceful handling — no client breakage, consistent enforcement.
---
## Mismatch Enforcement
| Option | Description | Selected |
|--------|-------------|----------|
| Hard 401 always | Token rejected immediately. Correct security posture. Browser updates are rare. | ✓ |
| Log-only soft mode | Log WARNING, allow request through. Safe for rollout but provides zero protection. | |
**User's choice:** Hard 401 always
**Notes:** The feature is meaningless if not enforced. Ship it enforced.
---
## Claude's Discretion
- Placement of fgp check in `get_current_user` (after user_nbf block)
- Migration grace: tokens without `fgp` claim allowed (forward-compat for existing sessions)
- `_compute_fgp` as a module-private helper called from both issuance and validation sites
- Test coverage cases (4 scenarios specified in CONTEXT.md specifics)
## Deferred Ideas
- Production key-rotation ceremony for SECRET_KEY — RUNBOOK documentation, future milestone
- Per-request device key pinning (stronger binding) — not needed for v1
@@ -0,0 +1,386 @@
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Pattern Map
**Mapped:** 2026-06-06
**Files analyzed:** 4
**Analogs found:** 4 / 4
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/services/auth.py` | service | request-response | `backend/services/auth.py` itself (extend existing) | exact — same file, additive change |
| `backend/deps/auth.py` | middleware/dependency | request-response | `backend/deps/auth.py` itself (extend user_nbf block) | exact — same file, additive block |
| `backend/api/auth.py` | controller | request-response | `backend/api/auth.py` itself (update 2 call sites) | exact — same file, call-site tweak |
| `backend/tests/test_auth_fgp.py` | test | request-response | `backend/tests/test_auth_deps.py` (NBF test pattern) | exact — same app harness, same fixture style |
---
## Pattern Assignments
### `backend/services/auth.py` (service, request-response)
**Analog:** Same file — additive change. Extend `create_access_token` and add `_compute_fgp` helper before it.
**Existing imports block** (lines 18-39):
```python
from __future__ import annotations
import base64
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
```
Both `import hashlib` (line 21) and `import hmac` (line 22) are already present. No new imports required.
**Existing `create_access_token` signature** (line 87):
```python
def create_access_token(user_id: str, role: str) -> str:
```
**Existing JWT payload dict** (lines 93-100) — `"fgp"` claim inserts here alongside existing claims:
```python
payload = {
"sub": str(user_id),
"role": role,
"typ": "access",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": str(uuid.uuid4()),
}
```
**Existing `hmac.compare_digest` usage pattern** (lines 419-425) — copy constant-time comparison idiom for the fgp check in `deps/auth.py`:
```python
if hmac.compare_digest(candidate_suffix.upper(), suffix):
```
**New `_compute_fgp` helper to add** (insert before `create_access_token` at line 87, per D-04):
```python
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
return hmac.new(
settings.secret_key.encode(),
(user_agent + accept_lang).encode(),
hashlib.sha256,
).hexdigest()[:16]
```
**Updated `create_access_token` signature** (D-05 — backward-compatible, empty-string defaults):
```python
def create_access_token(
user_id: str,
role: str,
user_agent: str = "",
accept_lang: str = "",
) -> str:
```
Add `"fgp": _compute_fgp(user_agent, accept_lang)` to the `payload` dict.
---
### `backend/deps/auth.py` (dependency/middleware, request-response)
**Analog:** Same file — additive block inserted after the existing `user_nbf` check.
**Existing imports block** (lines 23-33):
```python
import logging
import uuid
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User
from deps.db import get_db
from services import auth as auth_service
```
`hmac` is NOT imported in `deps/auth.py`. The fgp comparison uses `auth_service._compute_fgp` (called via the already-imported `auth_service`) plus `hmac.compare_digest`. Add `import hmac` at the top of this file.
**`get_current_user` signature** (lines 41-45) — `request: Request` already present:
```python
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db),
) -> User:
```
**Existing `user_nbf` block** (lines 62-85) — this is the exact structural model to follow for placement and exception guard order:
```python
# ── user_nbf check (D-02, D-03, D-04) ──────────────────────────────────────
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)
# ── end user_nbf check ──────────────────────────────────────────────────────
```
**Critical ordering rule** (line 81-84): `except HTTPException: raise` MUST precede `except Exception` — this guard re-raises any intentional HTTPException without it being swallowed. The fgp block raises `HTTPException` directly inside a try block; this guard is what ensures it propagates correctly.
**fgp validation block to insert after line 85** (per D-06, RESEARCH.md Pattern 3):
```python
# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
fgp_claim = payload.get("fgp", "")
if fgp_claim:
fgp_actual = auth_service._compute_fgp(
request.headers.get("User-Agent", ""),
request.headers.get("Accept-Language", ""),
)
if not hmac.compare_digest(fgp_claim, fgp_actual):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
# ── end fgp check ───────────────────────────────────────────────────────────
```
**Key difference from user_nbf block:** No try/except wrapper needed — `_compute_fgp` is pure computation with no I/O, so there is no infrastructure-failure path requiring fail-open. The fgp mismatch must always be enforced (D-03).
**Insertion point:** After line 85 (end of `user_nbf` block comment), before line 87 (`try: user_uuid = uuid.UUID(payload["sub"])`).
---
### `backend/api/auth.py` (controller, request-response)
**Analog:** Same file — two targeted call-site updates.
**Existing imports** (lines 21-42) — no new imports needed; `Request` already imported:
```python
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
```
**Login handler signature** (lines 190-197) — `request: Request` already present:
```python
@router.post("/login")
@limiter.limit("10/minute")
async def login(
request: Request,
body: LoginRequest,
response: Response,
session: AsyncSession = Depends(get_db),
):
```
**Login call site** (line 290) — current:
```python
access_token = auth_service.create_access_token(str(user.id), user.role)
```
Updated form (pass headers through):
```python
access_token = auth_service.create_access_token(
str(user.id),
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
```
**Refresh handler signature** (lines 320-326) — `request: Request` already present:
```python
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(
request: Request,
response: Response,
session: AsyncSession = Depends(get_db),
):
```
**Refresh call site** (line 364) — current:
```python
access_token = auth_service.create_access_token(user_id_str, user.role)
```
Updated form:
```python
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
```
---
### `backend/tests/test_auth_fgp.py` (test, request-response)
**Analog:** `backend/tests/test_auth_deps.py` — copy the full harness pattern (FakeRedis, `make_test_app`, `auth_client` fixture, `_create_user` helper).
**Test file module docstring pattern** (lines 1-15 of `test_auth_deps.py`):
```python
"""
Tests for backend/deps/auth.py — FastAPI authentication dependency chain.
...
"""
```
**Standard imports block** (lines 17-24 of `test_auth_deps.py`):
```python
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tests.test_auth_api import FakeRedis
```
New test file also needs: `import hmac`, `import hashlib`, `import base64`, `import time` for crafting tokens without `fgp` claim (FGP-03) and for testing hmac values.
**`make_test_app()` helper** (lines 29-52 of `test_auth_deps.py`) — copy verbatim; it creates a minimal `/test/me` route wired to `get_current_user`, with `app.state.redis = FakeRedis()`:
```python
def make_test_app():
from deps.auth import get_current_user
from db.models import User
test_app = FastAPI()
@test_app.get("/test/me")
async def get_me(current_user: User = Depends(get_current_user)):
return {"id": str(current_user.id), "role": current_user.role}
test_app.state.redis = FakeRedis()
return test_app
```
**`auth_client` fixture** (lines 55-71 of `test_auth_deps.py`) — copy verbatim; sets `get_db` override:
```python
@pytest_asyncio.fixture
async def auth_client(db_session: AsyncSession):
from deps.db import get_db
app = make_test_app()
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
```
**`_create_user` helper** (lines 74-89 of `test_auth_deps.py`) — copy verbatim:
```python
async def _create_user(db_session, role: str = "user", is_active: bool = True):
from db.models import User
from services.auth import hash_password
user = User(
id=uuid.uuid4(),
handle=f"user_{uuid.uuid4().hex[:6]}",
email=f"{uuid.uuid4().hex[:6]}@example.com",
password_hash=hash_password("testpassword"),
role=role,
is_active=is_active,
)
db_session.add(user)
await db_session.commit()
return user
```
**Standard test function pattern** (lines 94-108 of `test_auth_deps.py`) — `@pytest.mark.asyncio`, inline import of `create_access_token`, `auth_client.get` with `Authorization` header:
```python
@pytest.mark.asyncio
async def test_get_current_user_returns_user(auth_client, db_session):
from services.auth import create_access_token
user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user")
resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
```
**FGP-03 — crafting a token without `fgp` claim** — pattern from `test_auth_es256.py` (lines 52-75): use PyJWT directly with the ES256 private key extracted from `settings`. In the test file import `import jwt as _jwt`, `import config`, decode `settings.jwt_private_key` via `base64.b64decode`, and call `_jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")`. The `conftest._patch_es256_test_keys` autouse fixture (session-scoped) is already wired to all tests in `tests/` — no extra fixture needed in the new test file.
**Access to conftest `_patch_es256_test_keys` autouse fixture** — because it is `autouse=True` in `conftest.py`, every test in `backend/tests/` automatically gets it. The new `test_auth_fgp.py` benefits from it without any explicit reference.
---
## Shared Patterns
### `hmac.compare_digest` — constant-time comparison
**Source:** `backend/services/auth.py` line 419
**Apply to:** fgp validation block in `backend/deps/auth.py`
```python
if not hmac.compare_digest(fgp_claim, fgp_actual):
```
Never use `==` for token/fingerprint comparison. `hmac.compare_digest` is mandatory (CLAUDE.md SEC-06).
### `except HTTPException: raise` guard — before broad `except Exception`
**Source:** `backend/deps/auth.py` lines 81-84
**Apply to:** Any try block in `get_current_user` that raises an intentional `HTTPException`
```python
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)
```
The fgp block does NOT need this try/except (pure computation, no I/O). If placed inside the `user_nbf` try block, the guard order already handles it. If placed as a standalone `if` block outside any try, no wrapper is needed at all.
### `request.headers.get("X", "")` — header extraction with empty-string fallback
**Source:** `backend/api/auth.py` — idiomatic FastAPI; `request: Request` already injected in all callers
**Apply to:** Both `api/auth.py` call sites and `deps/auth.py` fgp validation block
```python
request.headers.get("User-Agent", "")
request.headers.get("Accept-Language", "")
```
### Error response format for 401
**Source:** `backend/deps/auth.py` lines 75-80
**Apply to:** fgp mismatch raise
```python
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
```
All 401 raises in `get_current_user` include `headers={"WWW-Authenticate": "Bearer"}`.
---
## No Analog Found
All four files have close analogs. No gaps.
---
## Metadata
**Analog search scope:** `backend/services/`, `backend/deps/`, `backend/api/`, `backend/tests/`
**Files scanned:** 5 (services/auth.py, deps/auth.py, api/auth.py, tests/test_auth_deps.py, tests/test_auth_es256.py, tests/conftest.py)
**Pattern extraction date:** 2026-06-06
@@ -0,0 +1,469 @@
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Research
**Researched:** 2026-06-06
**Domain:** JWT access token fingerprinting via HMAC-SHA256 claim
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Use `settings.secret_key` (`SECRET_KEY` env var) as the HMAC key — no new env var.
- **D-02:** Missing `User-Agent` or `Accept-Language` falls back to `""`. The `fgp` claim is always computed and always validated — no skip path.
- **D-03:** Fingerprint mismatch raises HTTP 401 immediately with `detail="Token fingerprint mismatch"`. No soft mode.
- **D-04:** Helper `_compute_fgp(user_agent: str, accept_lang: str) -> str` defined in `backend/services/auth.py` (module-private). Returns `hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16]`.
- **D-05:** `create_access_token(user_id, role)` gains `user_agent: str = ""` and `accept_lang: str = ""` parameters. Default empty strings preserve backward compatibility with existing tests.
- **D-06:** fgp validation in `get_current_user` placed after the `user_nbf` block. If `fgp_claim` is empty (old token without claim) → allow (graceful migration). If non-empty and mismatch → 401.
### Claude's Discretion
None stated.
### Deferred Ideas (OUT OF SCOPE)
- Key rotation process for `SECRET_KEY`
- Per-request fingerprint rotation / device key pinning
</user_constraints>
---
## Summary
Phase 7.4 is a pure backend code change with no schema migrations, no new endpoints, and no frontend work. The change set is small and surgically scoped to three files:
1. `backend/services/auth.py` — add `_compute_fgp` helper, extend `create_access_token` signature, embed `fgp` claim in payload.
2. `backend/deps/auth.py` — add fgp validation block after the `user_nbf` check.
3. `backend/api/auth.py` — pass request headers to `create_access_token` at the two call sites (login and refresh handlers).
4. `backend/tests/test_auth_fgp.py` — new test file with four test cases following Phase 7.2/7.3 patterns.
No new dependencies. No new environment variables. `hashlib` is already imported in `services/auth.py` (line 21). `hmac` is already imported (line 22). `request.headers` is already accessible in both call sites and in `get_current_user`. The conftest `_patch_es256_test_keys` autouse fixture already covers all tests.
**Primary recommendation:** Three targeted edits to existing files plus one new test file — total diff should be under 60 lines of production code.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| fgp claim creation | API / Backend (service layer) | — | `_compute_fgp` in `services/auth.py`; pure Python, no FastAPI coupling |
| fgp claim embedding | API / Backend (service layer) | — | `create_access_token` builds the JWT payload |
| fgp claim validation | API / Backend (dependency layer) | — | `get_current_user` in `deps/auth.py` validates on every authenticated request |
| Header extraction | API / Backend (router/dep layer) | — | `request.headers.get(...)` idiomatic FastAPI pattern; already in both callers |
| HMAC key storage | Config / env | — | `settings.secret_key` — no new field needed |
---
## Standard Stack
No new packages. All required modules are already installed and imported.
### Already Available
| Module | Location | Already Imported? | Notes |
|--------|----------|-------------------|-------|
| `hmac` | stdlib | Yes — `services/auth.py:22` [VERIFIED: read file] | Use `hmac.new(...)` (Python stdlib name is `hmac.new`) |
| `hashlib` | stdlib | Yes — `services/auth.py:21` [VERIFIED: read file] | `hashlib.sha256` used as digestmod |
| `hmac.compare_digest` | stdlib | Already used — `services/auth.py:419` [VERIFIED: read file] | Same pattern as backup code comparison |
| `request.headers` | FastAPI `Request` | `request: Request` already in `get_current_user` signature [VERIFIED: read file] | Pattern: `request.headers.get("User-Agent", "")` |
### Installation
No new packages to install.
---
## Package Legitimacy Audit
Not applicable — no new packages.
---
## Architecture Patterns
### System Architecture Diagram
```
POST /api/auth/login POST /api/auth/refresh
| |
| request.headers.get(...) | request.headers.get(...)
v v
services/auth.create_access_token(user_id, role, user_agent, accept_lang)
|
| _compute_fgp(user_agent, accept_lang) -> hexdigest[:16]
| payload["fgp"] = fgp_value
| jwt.encode(payload, private_pem, algorithm="ES256")
v
JWT access token (fgp claim embedded)
Every authenticated request:
|
v
deps/auth.get_current_user(request, credentials, session)
|
| decode_access_token -> payload
| user_nbf check (Phase 7.2 block, lines 6285)
|
| [NEW] fgp_claim = payload.get("fgp", "")
| if fgp_claim:
| fgp_actual = _compute_fgp(request.headers.get(...), request.headers.get(...))
| if not hmac.compare_digest(fgp_claim, fgp_actual):
| raise HTTP 401 "Token fingerprint mismatch"
|
v
uuid.UUID(payload["sub"]) -> User lookup
```
### Recommended Project Structure
No new files in `src/`. One new test file:
```
backend/
├── services/auth.py # add _compute_fgp + extend create_access_token
├── deps/auth.py # add fgp validation block after user_nbf
├── api/auth.py # update 2 call sites: login + refresh
└── tests/
└── test_auth_fgp.py # new file, 4 test cases
```
### Pattern 1: fgp helper (_compute_fgp)
**What:** Module-private function in `services/auth.py`, placed before `create_access_token`.
**When to use:** Called from `create_access_token` (at issuance) and referenced by `get_current_user` via import.
```python
# Source: CONTEXT.md D-04
import hmac as _hmac_mod # avoid shadowing module if 'hmac' name is also used as variable
import hashlib
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint of User-Agent + Accept-Language (D-04)."""
return _hmac_mod.new(
settings.secret_key.encode(),
(user_agent + accept_lang).encode(),
hashlib.sha256,
).hexdigest()[:16]
```
**Note on import name:** `hmac` is already imported at line 22 of `services/auth.py` as `import hmac`. The `hmac` module exposes `.new()` directly. No rename needed — `hmac.new(...)` works. [VERIFIED: stdlib docs, `hmac` module]
### Pattern 2: create_access_token signature extension
**What:** Add two keyword parameters with empty-string defaults to `create_access_token`.
**Exact current signature (line 87):** `def create_access_token(user_id: str, role: str) -> str:` [VERIFIED: read file]
Updated signature:
```python
def create_access_token(
user_id: str,
role: str,
user_agent: str = "",
accept_lang: str = "",
) -> str:
```
Inside the function, add `"fgp": _compute_fgp(user_agent, accept_lang)` to the `payload` dict alongside existing claims (`sub`, `role`, `typ`, `iat`, `exp`, `jti`). [VERIFIED: payload dict at lines 93100 of services/auth.py]
### Pattern 3: fgp validation block in get_current_user
**Placement:** After line 85 (end of `user_nbf` block), before line 87 (`uuid.UUID(payload["sub"])`). [VERIFIED: read deps/auth.py]
**Structural model — copy the user_nbf block pattern** (try / except HTTPException: raise / except Exception: log + fail-open), but with a critical difference from NBF: the fgp mismatch must NOT be swallowed by the broad except. The mismatch raise is an explicit `HTTPException` inside the try block, which the `except HTTPException: raise` guard re-raises correctly.
```python
# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
fgp_claim = payload.get("fgp", "")
if fgp_claim:
fgp_actual = auth_service._compute_fgp(
request.headers.get("User-Agent", ""),
request.headers.get("Accept-Language", ""),
)
if not hmac.compare_digest(fgp_claim, fgp_actual):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
# ── end fgp check ───────────────────────────────────────────────────────────
```
**Note:** `_compute_fgp` is a module-private function in `services/auth.py`. `deps/auth.py` already imports `from services import auth as auth_service` (line 32). Calling `auth_service._compute_fgp(...)` follows the existing import pattern. Alternatively, the function can be made public (no underscore), but keeping it private signals it is not part of the public service API. Either approach is fine — the planner should decide. [ASSUMED: naming convention choice]
### Pattern 4: Caller updates in api/auth.py
**Login handler (line 290):** [VERIFIED: read api/auth.py]
```python
# Before:
access_token = auth_service.create_access_token(str(user.id), user.role)
# After:
access_token = auth_service.create_access_token(
str(user.id),
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
```
**Refresh handler (line 364):** [VERIFIED: read api/auth.py]
```python
# Before:
access_token = auth_service.create_access_token(user_id_str, user.role)
# After:
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
```
Both handlers already have `request: Request` in their signature. [VERIFIED: read api/auth.py lines 192195 and 321325]
### Anti-Patterns to Avoid
- **Calling `_compute_fgp` in `deps/auth.py` directly instead of via `auth_service`:** `deps/auth.py` already imports `auth_service` — use it for consistency. Do not add a second import of the function.
- **Wrapping the fgp `HTTPException` raise in a broad except:** The `except HTTPException: raise` guard at line 81 of `deps/auth.py` ensures the intentional 401 is never swallowed. The fgp check must be structured inside the same or a separate try block with the same guard. [VERIFIED: read deps/auth.py lines 8184]
- **Treating `fgp_claim == ""` as a mismatch:** Empty fgp_claim means the token predates this phase — it must be allowed (graceful migration, D-06).
- **Using `==` instead of `hmac.compare_digest`:** Constant-time comparison is mandatory for all token comparisons (SEC-06, CLAUDE.md).
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Constant-time string comparison | Custom loop | `hmac.compare_digest` | SEC-06 — timing attack prevention; already established in codebase |
| HMAC computation | Manual SHA-256 + XOR | `hmac.new(..., hashlib.sha256)` | Correct keyed-HMAC semantics; raw SHA-256 is not an HMAC |
---
## Common Pitfalls
### Pitfall 1: fgp mismatch swallowed by broad except
**What goes wrong:** The fgp `HTTPException` is raised inside the try block but the broad `except Exception` catches it before `except HTTPException: raise` runs — only if the guard order is reversed.
**Why it happens:** Python evaluates except clauses in order; if `except Exception` appears before `except HTTPException`, the HTTPException is matched and swallowed.
**How to avoid:** The existing pattern in `deps/auth.py` already has `except HTTPException: raise` before `except Exception` (lines 8184). Maintain this order. [VERIFIED: read file]
**Warning signs:** fgp mismatch returns 200 or 500 instead of 401.
### Pitfall 2: test fixtures call create_access_token without new params — will fgp validation break?
**What goes wrong:** After Phase 7.4, all test-issued tokens will carry `fgp` computed from `("", "")` because `user_agent=""` and `accept_lang=""` are the defaults. Test requests sent without User-Agent/Accept-Language headers will also compute `_compute_fgp("", "")`. These will match — no test breakage.
**Why it happens:** Default parameters make the no-arg call `create_access_token(user_id, role)` emit `fgp = hmac("" + "")[:16]`. An unauthenticated test request with no headers also computes the same value. Match succeeds.
**How to avoid:** No action required for existing tests. The test for "wrong fgp" must explicitly issue a token with headers A then make a request with different headers B.
**Warning signs:** All existing auth tests still pass (expected); they should not require changes.
### Pitfall 3: hmac.new vs hmac.HMAC
**What goes wrong:** `hmac.new(...)` is the correct stdlib constructor. Attempting `hmac.HMAC(...)` directly is not the intended public API.
**How to avoid:** Use `hmac.new(key, msg, digestmod)` exactly as specified in D-04.
**Warning signs:** `AttributeError: module 'hmac' has no attribute 'HMAC'` (HMAC is the class, new() is the factory).
### Pitfall 4: _compute_fgp visibility across module boundary
**What goes wrong:** `deps/auth.py` calls `auth_service._compute_fgp(...)`. Python name mangling does NOT apply to module-level `_` names — they are accessible from other modules, just conventionally private. This will work fine.
**How to avoid:** No special handling needed; the underscore is a convention, not an enforcement. [VERIFIED: Python docs]
### Pitfall 5: conftest fixtures will produce fgp-bearing tokens after Phase 7.4
**What goes wrong:** After the change, `create_access_token(str(user_id), "user")` in conftest (and all test files) will embed `fgp` = `hmac("", "")[:16]`. Requests made by `auth_client` or `async_client` without `User-Agent`/`Accept-Language` headers compute the same fingerprint → 200. This is the intended behaviour.
**Warning signs:** If a test explicitly sets `User-Agent` in its request headers, the token issued by the fixture (with `user_agent=""`) will mismatch the request headers → 401. This would only affect tests that set custom `User-Agent` headers, which currently none do. [VERIFIED: grep of conftest and test_auth_deps.py]
---
## Code Examples
### Complete fgp Helper (from CONTEXT.md D-04)
```python
# Source: CONTEXT.md D-04 (canonical)
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
return hmac.new(
settings.secret_key.encode(),
(user_agent + accept_lang).encode(),
hashlib.sha256,
).hexdigest()[:16]
```
### Test Case Structure (4 required by CONTEXT.md)
```python
# Source: CONTEXT.md §Specific Ideas
# Test 1 — correct fgp → 200
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
"/test/me",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "Mozilla/5.0",
"Accept-Language": "en",
},
)
assert resp.status_code == 200
# Test 2 — wrong fgp → 401
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
"/test/me",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "different-agent", # mismatch
"Accept-Language": "en",
},
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Token fingerprint mismatch"
# Test 3 — token without fgp claim → 200 (migration grace)
# Manually craft a token without "fgp" in the payload
payload_no_fgp = {
"sub": str(user_id), "role": "user", "typ": "access",
"iat": ..., "exp": ..., "jti": str(uuid.uuid4()),
}
token_no_fgp = jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
resp = await auth_client.get("/test/me", headers={"Authorization": f"Bearer {token_no_fgp}"})
assert resp.status_code == 200
# Test 4 — missing User-Agent → empty-string binding works
token = create_access_token(user_id, "user") # user_agent="", accept_lang=""
resp = await auth_client.get(
"/test/me",
headers={"Authorization": f"Bearer {token}"},
# No User-Agent, no Accept-Language — both default to ""
)
assert resp.status_code == 200
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Access tokens had no device binding | Tokens carry `fgp` HMAC claim | Phase 7.4 | Stolen tokens can only be replayed from same UA/language context |
---
## Detailed File Change Map
| File | Change | Lines affected (approx) |
|------|--------|-------------------------|
| `backend/services/auth.py` | Add `_compute_fgp` helper (6 lines) before `create_access_token`; extend `create_access_token` signature (+2 params); add `"fgp"` to payload dict (+1 line) | ~10 lines added |
| `backend/deps/auth.py` | Add fgp validation block after line 85 | ~9 lines added |
| `backend/api/auth.py` | Update login call site (line 290) and refresh call site (line 364) to pass headers | ~8 lines changed |
| `backend/tests/test_auth_fgp.py` | New file: 4 test functions + 1 helper fixture | ~100 lines new |
**Total production code change: ~27 lines.** Well within the ≤50 line bug-fix ceiling for any single plan.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest + pytest-asyncio |
| Config file | `backend/pytest.ini` (or `pyproject.toml`) |
| Quick run command | `pytest tests/test_auth_fgp.py -v` |
| Full suite command | `pytest -v` |
### Phase Requirements → Test Map
| Behaviour | Test ID | Test Type | Automated Command |
|-----------|---------|-----------|-------------------|
| Token with correct fgp → 200 | FGP-01 | integration | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` |
| Token with wrong fgp → 401 | FGP-02 | integration | `pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x` |
| Token without fgp claim → 200 (migration grace) | FGP-03 | integration | `pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x` |
| Missing headers → empty-string binding works | FGP-04 | integration | `pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x` |
### Sampling Rate
- **Per task commit:** `pytest tests/test_auth_fgp.py tests/test_auth_deps.py -x`
- **Per wave merge:** `pytest -v` (all 411+ tests)
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/test_auth_fgp.py` — new file, covers FGP-01..04. Must be created in Wave 0 as xfail stubs before implementation.
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | `hmac.compare_digest` for all fingerprint comparison |
| V3 Session Management | yes | fgp binds access token to originating client context |
| V5 Input Validation | yes | Headers extracted via `.get(..., "")` — no injection possible; HMAC treats as opaque bytes |
| V6 Cryptography | yes | `hmac.new(..., hashlib.sha256)` — standard keyed HMAC, not custom |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Stolen access token replay from different device | Spoofing / Elevation | fgp mismatch → HTTP 401 |
| Timing attack on fingerprint comparison | Information Disclosure | `hmac.compare_digest` (constant-time) |
| Empty-string collision (all CLI clients share one fingerprint) | Spoofing | Accepted by D-02 — deliberate design; equivalent to pre-Phase-7.4 security for CLI clients |
---
## Open Questions (RESOLVED)
1. **`_compute_fgp` visibility: private (`_compute_fgp`) vs public (`compute_fgp`)?**
- What we know: `deps/auth.py` needs to call it; it is currently planned as `_compute_fgp` (module-private by convention).
- What's unclear: The CONTEXT.md specifies `_compute_fgp`. Calling `auth_service._compute_fgp` from `deps/auth.py` works but is unconventional.
- Recommendation: Keep the underscore per D-04; `auth_service._compute_fgp` is acceptable in this tightly coupled internal boundary. Alternatively, expose as `compute_fgp` (no underscore) for cleaner inter-module use — either is consistent with the codebase. The planner should pick one and document it. [ASSUMED: naming decision]
- **RESOLVED:** Keep private (`_compute_fgp`) per D-04. The underscore convention signals it is not part of the public service API. `auth_service._compute_fgp(...)` in `deps/auth.py` is acceptable at this tightly coupled internal boundary.
2. **Should the fgp check be inside the existing user_nbf try block or in its own try block?**
- What we know: The user_nbf block is a try/except with fail-open on Redis errors (D-04 of Phase 7.2). The fgp check has no external I/O — it cannot fail due to infrastructure.
- What's unclear: Structurally, putting fgp inside the same try block would work, but it conflates two different concerns. A separate, unconditional if-block (no try/except needed) is cleaner.
- Recommendation: Place fgp as a plain `if fgp_claim:` block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper needed for fgp — it is pure computation, not I/O. [ASSUMED]
- **RESOLVED:** Place as a standalone `if fgp_claim:` block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper — pure computation, no I/O. The existing `except HTTPException: raise` guard in any wrapping try block would still catch the mismatch 401 correctly, but keeping it outside is cleaner.
---
## Environment Availability
Step 2.6: SKIPPED — this phase is a pure backend code change with no external dependencies beyond the existing Python stdlib and already-installed PyJWT. No new tools, services, or runtimes required.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `_compute_fgp` naming convention (underscore vs public) | Open Questions #1 | Cosmetic only — either works at runtime |
| A2 | fgp check is a plain if-block, not wrapped in try/except | Open Questions #2 | Cosmetic only — try/except around pure computation is harmless but unnecessary |
---
## Sources
### Primary (HIGH confidence)
- `backend/services/auth.py` — verified exact current signatures, imports, payload structure, existing `hmac.compare_digest` usage [VERIFIED: read file]
- `backend/deps/auth.py` — verified `get_current_user` structure, user_nbf block lines 6285, `request: Request` in signature, `except HTTPException: raise` guard [VERIFIED: read file]
- `backend/api/auth.py` — verified login call site (line 290), refresh call site (line 364), both have `request: Request` [VERIFIED: read file]
- `backend/config.py` — verified `secret_key: str = "CHANGEME"` at line 31 [VERIFIED: read file]
- `backend/tests/conftest.py` — verified autouse `_patch_es256_test_keys` fixture, `auth_user`/`admin_user` fixtures call `create_access_token(str(user_id), role)` [VERIFIED: read file]
- `backend/tests/test_auth_deps.py` — verified Phase 7.2 NBF test pattern (FakeRedis, make_test_app, `_create_user` helper) [VERIFIED: read file]
- `backend/tests/test_auth_es256.py` — verified Phase 7.3 test pattern (autouse `es256_keys` fixture, test naming convention) [VERIFIED: read file]
- CONTEXT.md — locked decisions D-01 through D-06 [VERIFIED: read file]
### Secondary (MEDIUM confidence)
- `grep -rn "create_access_token"` output — confirmed only two production call sites exist (`api/auth.py:290` login and `api/auth.py:364` refresh); all other hits are test files [VERIFIED: bash grep]
- Python stdlib `hmac` module — `hmac.new(key, msg, digestmod)` is the correct constructor [CITED: docs.python.org/3/library/hmac.html]
---
## Metadata
**Confidence breakdown:**
- File change map: HIGH — read all target files directly
- Standard stack: HIGH — no new packages; all modules verified present
- Test patterns: HIGH — read Phase 7.2/7.3 test files directly
- Architecture: HIGH — CONTEXT.md fully specifies implementation
- Pitfalls: HIGH — derived from direct code inspection
**Research date:** 2026-06-06
**Valid until:** N/A — this is a single-session phase with no external dependencies
@@ -0,0 +1,65 @@
---
phase: 7.4
slug: 07.4-security-token-fingerprinting-token-binding-inserted
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-06
---
# Phase 7.4 — 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_fgp.py -v` |
| **Full suite command** | `pytest -v` |
| **Estimated runtime** | ~30 seconds |
---
## Sampling Rate
- **After every task commit:** Run `pytest tests/test_auth_fgp.py tests/test_auth_deps.py -x`
- **After every plan wave:** Run `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 |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| FGP-01 | 01 | 0 | D-04/D-05 | T-7.4-01 | xfail stubs created for all 4 test cases | unit | `pytest tests/test_auth_fgp.py -v` | ❌ W0 | ⬜ pending |
| FGP-02 | 01 | 1 | D-04 | T-7.4-01 | `_compute_fgp` helper returns 16-char hex HMAC | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
| FGP-03 | 01 | 1 | D-05 | — | `create_access_token` embeds `fgp` claim | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
| FGP-04 | 01 | 1 | D-06 | T-7.4-01 | Correct fgp → 200 | integration | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
| FGP-05 | 01 | 1 | D-03/D-06 | T-7.4-01 | Wrong fgp → 401 "Token fingerprint mismatch" | integration | `pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x` | ✅ | ⬜ pending |
| FGP-06 | 01 | 1 | D-06 | — | Token without fgp claim → 200 (migration grace) | integration | `pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x` | ✅ | ⬜ pending |
| FGP-07 | 01 | 1 | D-02 | — | Missing headers → empty-string binding works | integration | `pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `tests/test_auth_fgp.py` — new file with 4 xfail stubs covering FGP-01..04 (correct match, wrong fgp, no fgp claim, missing headers)
*Existing infrastructure covers all other requirements (conftest autouse fixtures, pytest-asyncio, FakeRedis).*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Full suite regression check | All prior phases | Confirm 0 regressions from signature changes | Run `pytest -v` and verify count ≥ prior passing total |