Files

7.1 KiB
Raw Permalink Blame History

Phase 7.4: Security — Token Fingerprinting / Token Binding - Context

Gathered: 2026-06-06 Status: Ready for planning

## 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.

## 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.

<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>

## 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.
## 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.

Phase: 07.4-security-token-fingerprinting-token-binding-inserted Context gathered: 2026-06-06