# 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
