chore: merge executor worktree (worktree-agent-acc04a00e1a55bd86)

This commit is contained in:
curo1305
2026-06-06 22:16:16 +02:00
12 changed files with 367 additions and 39 deletions
@@ -0,0 +1,158 @@
---
phase: 07.4-security-token-fingerprinting-token-binding-inserted
plan: "02"
subsystem: auth
tags: [jwt, fgp, token-binding, token-fingerprinting, hmac, security, wave-1]
# Dependency graph
requires:
- phase: 07.4-01
provides: Wave 0 xfail stubs for FGP-01..FGP-04 in test_auth_fgp.py
provides:
- _compute_fgp helper in services/auth.py (16-char hex HMAC-SHA256 fingerprint)
- fgp claim in every issued JWT access token
- fgp validation block in get_current_user (deps/auth.py)
- Login + refresh call sites in api/auth.py pass User-Agent + Accept-Language
- 4 passing FGP integration tests (promoted from xfail stubs)
affects:
- All API endpoints using get_current_user — fgp now validated on every request
- Test infrastructure — _TEST_USER_AGENT constant added to conftest.py
# Tech tracking
tech-stack:
added: []
patterns:
- "_compute_fgp HMAC-SHA256 with settings.secret_key as HMAC key (D-04)"
- "hmac.compare_digest for constant-time fgp comparison (SEC-06)"
- "Empty fgp_claim allows request — migration grace for pre-7.4 tokens (D-06)"
- "_TEST_USER_AGENT constant in conftest.py for test infrastructure fgp consistency"
key-files:
created: []
modified:
- backend/services/auth.py
- backend/deps/auth.py
- backend/api/auth.py
- backend/tests/test_auth_fgp.py
- backend/tests/conftest.py
- backend/tests/test_auth_deps.py
- backend/tests/test_cloud.py
- backend/tests/test_documents.py
- backend/tests/test_security_headers.py
- backend/main.py
- frontend/package.json
key-decisions:
- "_compute_fgp defined in services/auth.py (not deps/auth.py) so both token issuance and validation call the same implementation via auth_service._compute_fgp"
- "fgp validation block outside try/except — pure computation with no I/O, so no fail-open path needed (unlike user_nbf Redis check)"
- "_TEST_USER_AGENT='docuvault-test/1.0' added to conftest.py; async_client fixture and all token-issuing fixtures now use this constant to ensure fgp consistency in test environments"
- "FGP-04 test sends User-Agent='' explicitly because httpx.AsyncClient defaults to python-httpx/X.Y.Z which would mismatch the empty-string fgp"
# Metrics
duration: 35min
completed: 2026-06-06
---
# Phase 07.4 Plan 02: Token Fingerprinting (FGP) Implementation Summary
**Token fingerprinting end-to-end: HMAC-SHA256 fgp claim embedded in every access token; validated in get_current_user with hmac.compare_digest; login+refresh pass headers; all 4 FGP tests passing**
## Performance
- **Duration:** ~35 min
- **Started:** 2026-06-06T20:00:00Z
- **Completed:** 2026-06-06T20:35:00Z
- **Tasks:** 3
- **Files modified:** 11
## Accomplishments
- Added `_compute_fgp(user_agent, accept_lang)` helper to `backend/services/auth.py`
- Returns 16-char hex HMAC-SHA256 prefix using `settings.secret_key` as key
- Uses existing `import hmac` and `import hashlib` — no new imports needed
- Extended `create_access_token` signature with `user_agent: str = ""` and `accept_lang: str = ""`
- Backward-compatible defaults (D-05)
- Embeds `"fgp": _compute_fgp(user_agent, accept_lang)` in JWT payload
- Added fgp validation block to `get_current_user` in `backend/deps/auth.py`
- Added `import hmac`
- Placed after `user_nbf` check and before UUID parse
- Empty `fgp_claim` → skip (migration grace for pre-7.4 tokens, D-06)
- Non-empty `fgp_claim` → recompute and compare with `hmac.compare_digest`
- Mismatch → HTTP 401 "Token fingerprint mismatch" (D-03)
- Not wrapped in try/except — pure computation, no I/O (T-07.4-04 mitigated)
- Updated both `create_access_token` call sites in `backend/api/auth.py`
- Login handler: passes `User-Agent` and `Accept-Language` headers
- Refresh handler: same header passthrough
- Promoted all 4 xfail stubs in `test_auth_fgp.py` to real assertions (0 xfail → 4 passed)
- Version bumped to 0.1.3 (backend/main.py and frontend/package.json)
- Full pytest suite: 404 passed, 4 skipped, 7 xfailed, 0 failed
## Task Commits
1. **Task 1: Add _compute_fgp + extend create_access_token** - `25c9142`
2. **Task 2: Add fgp validation block to get_current_user** - `1420180`
3. **Task 3: Update call sites, promote tests, version bump (+ Rule 1 fix)** - `61b1e04`
## Files Created/Modified
- `backend/services/auth.py` — added `_compute_fgp` helper + extended `create_access_token` signature + fgp payload key
- `backend/deps/auth.py` — added `import hmac` + fgp validation block after user_nbf check
- `backend/api/auth.py` — login and refresh call sites updated to pass User-Agent + Accept-Language headers
- `backend/tests/test_auth_fgp.py` — 4 xfail stubs promoted to real passing integration tests
- `backend/tests/conftest.py``_TEST_USER_AGENT` constant + updated async_client fixture + updated auth_user/second_auth_user/admin_user fixtures
- `backend/tests/test_auth_deps.py` — import `_TEST_USER_AGENT`; updated auth_client fixture + all create_access_token calls
- `backend/tests/test_cloud.py` — import `_TEST_USER_AGENT`; updated `_create_user_and_token` helper
- `backend/tests/test_documents.py` — updated 3 inline create_access_token calls
- `backend/tests/test_security_headers.py` — import `_TEST_USER_AGENT`; updated headers_client + token creation
- `backend/main.py` — version 0.1.2 → 0.1.3
- `frontend/package.json` — version 0.1.2 → 0.1.3
## Decisions Made
- `_compute_fgp` is defined in `services/auth.py` (service layer) and accessed from `deps/auth.py` via the already-imported `auth_service._compute_fgp` — avoids circular import and keeps fingerprint logic centralized in the service layer
- fgp validation block is NOT wrapped in try/except because `_compute_fgp` is pure computation with no I/O infrastructure that could fail; unlike the Redis user_nbf check, there is no "fail-open" scenario needed
- `_TEST_USER_AGENT = "docuvault-test/1.0"` constant added to conftest.py; all test clients and token-issuing fixtures use this constant to ensure fgp consistency across the test suite
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Test infrastructure fgp mismatch: httpx default User-Agent vs empty-string fgp binding**
- **Found during:** Task 3 verification
- **Issue:** When running `pytest tests/test_auth_api.py tests/test_auth_deps.py`, 10 tests failed with 401. The root cause: `auth_user`, `admin_user`, `second_auth_user` fixtures call `create_access_token(...)` without `user_agent`, so tokens are bound to `fgp("")`. But `httpx.AsyncClient` sends `User-Agent: python-httpx/0.28.1` by default. The fgp check in `get_current_user` recomputed the fingerprint from this non-empty User-Agent and found it didn't match the token's `fgp("")` → 401.
- **Fix:** Added `_TEST_USER_AGENT = "docuvault-test/1.0"` to conftest.py. Updated `async_client` fixture to send this constant as the User-Agent. Updated `auth_user`, `second_auth_user`, `admin_user` fixtures to pass `user_agent=_TEST_USER_AGENT` to `create_access_token`. Updated `test_auth_deps.py`, `test_cloud.py`, `test_documents.py`, and `test_security_headers.py` to use the same constant.
- **Files modified:** `backend/tests/conftest.py`, `backend/tests/test_auth_deps.py`, `backend/tests/test_cloud.py`, `backend/tests/test_documents.py`, `backend/tests/test_security_headers.py`
- **Commit:** `61b1e04` (part of Task 3 commit)
**2. [Rule 1 - Bug] FGP-04 test logic: httpx sends default User-Agent even when not specified**
- **Found during:** Task 3 first test run
- **Issue:** `test_missing_headers_empty_string_binding` created a token with empty-string fgp (no user-agent args) and sent a request "with no User-Agent". But httpx.AsyncClient adds `User-Agent: python-httpx/0.28.1` automatically → fgp mismatch → 401 instead of 200.
- **Fix:** Updated the test to explicitly set `"User-Agent": ""` in the request headers, matching what the server would compute (`_compute_fgp("", "")`) and the token's fgp claim.
- **Files modified:** `backend/tests/test_auth_fgp.py`
- **Commit:** `61b1e04`
## Known Stubs
None. All 4 FGP tests are real assertions producing passing results.
## Threat Flags
None — this plan implements the mitigations described in the threat model:
- T-07.4-01 (token replay): fgp mismatch now returns 401
- T-07.4-02 (timing attack): hmac.compare_digest used
- T-07.4-04 (exception swallowing): fgp block outside try/except
## Self-Check: PASSED
- `backend/services/auth.py` contains `def _compute_fgp` and `"fgp": _compute_fgp(user_agent, accept_lang)`
- `backend/deps/auth.py` contains `import hmac`, `"Token fingerprint mismatch"`, `fgp_claim = payload.get("fgp", "")`, `hmac.compare_digest(fgp_claim, fgp_actual)`
- `backend/api/auth.py` shows 2 matches for `user_agent=request.headers`
- `backend/main.py` version is 0.1.3
- `frontend/package.json` version is 0.1.3
- Commits 25c9142, 1420180, 61b1e04 exist in git log
- `pytest tests/test_auth_fgp.py -v` reports 4 PASSED
- Full suite: 404 passed, 4 skipped, 7 xfailed, 0 failed
+12 -2
View File
@@ -287,7 +287,12 @@ async def login(
) )
# Issue tokens # Issue tokens
access_token = auth_service.create_access_token(str(user.id), user.role) 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", ""),
)
raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me) raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
_set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me) _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
@@ -361,7 +366,12 @@ async def refresh_token(
# Set new refresh cookie # Set new refresh cookie
_set_refresh_cookie(response, new_raw) _set_refresh_cookie(response, new_raw)
access_token = auth_service.create_access_token(user_id_str, user.role) 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", ""),
)
return { return {
"access_token": access_token, "access_token": access_token,
"user": { "user": {
+19
View File
@@ -20,6 +20,7 @@ Usage in route handlers:
): ):
... ...
""" """
import hmac
import logging import logging
import uuid import uuid
@@ -84,6 +85,24 @@ async def get_current_user(
_logger.warning("Redis user_nbf check failed (fail-open): %s", exc) _logger.warning("Redis user_nbf check failed (fail-open): %s", exc)
# ── end user_nbf check ────────────────────────────────────────────────────── # ── end user_nbf check ──────────────────────────────────────────────────────
# ── 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 ───────────────────────────────────────────────────────────
try: try:
user_uuid = uuid.UUID(payload["sub"]) user_uuid = uuid.UUID(payload["sub"])
except (KeyError, ValueError) as exc: except (KeyError, ValueError) as exc:
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.2", lifespan=lifespan) app = FastAPI(title="Document Scanner API", version="0.1.3", lifespan=lifespan)
# Rate limiter state (slowapi) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+18 -2
View File
@@ -84,10 +84,25 @@ def validate_password_strength(password: str) -> None:
# ── JWT helpers ───────────────────────────────────────────────────────────────── # ── JWT helpers ─────────────────────────────────────────────────────────────────
def create_access_token(user_id: str, role: str) -> str: 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]
def create_access_token(
user_id: str,
role: str,
user_agent: str = "",
accept_lang: str = "",
) -> str:
"""Return a signed JWT access token. """Return a signed JWT access token.
Claims: sub=user_id, role=role, typ='access', exp=now+access_token_expire_minutes. Claims: sub=user_id, role=role, typ='access', exp=now+access_token_expire_minutes,
fgp=fingerprint computed from client User-Agent + Accept-Language headers (D-05).
""" """
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
@@ -97,6 +112,7 @@ def create_access_token(user_id: str, role: str) -> str:
"iat": now, "iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes), "exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": str(uuid.uuid4()), "jti": str(uuid.uuid4()),
"fgp": _compute_fgp(user_agent, accept_lang),
} }
private_pem = base64.b64decode(settings.jwt_private_key).decode() private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256") return jwt.encode(payload, private_pem, algorithm="ES256")
+27 -5
View File
@@ -30,6 +30,13 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
# ── Phase 7.4: test-client user-agent constant ────────────────────────────────
# Tokens issued by test fixtures must embed a fgp claim matching the User-Agent
# the test client will send. httpx.AsyncClient defaults to "python-httpx/X.Y.Z"
# which varies by library version. We lock both token creation and the client
# to this constant so the fgp check in get_current_user always passes in tests.
_TEST_USER_AGENT = "docuvault-test/1.0"
# ── Service availability ────────────────────────────────────────────────────── # ── Service availability ──────────────────────────────────────────────────────
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
@@ -146,13 +153,23 @@ async def db_session():
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def async_client(db_session: AsyncSession): async def async_client(db_session: AsyncSession):
"""Async HTTP test client with the DB dependency overridden to use in-memory SQLite.""" """Async HTTP test client with the DB dependency overridden to use in-memory SQLite.
Phase 7.4: sets a fixed User-Agent (_TEST_USER_AGENT) so that the fgp claim
embedded in tokens from auth_user/admin_user fixtures matches what the client
sends. Without this, httpx's default 'python-httpx/X.Y.Z' UA would differ
from the empty-string UA used when creating tokens without explicit headers.
"""
from deps.db import get_db from deps.db import get_db
from main import app from main import app
app.dependency_overrides[get_db] = lambda: db_session app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"User-Agent": _TEST_USER_AGENT},
) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -273,7 +290,10 @@ async def auth_user(db_session: AsyncSession):
db_session.add(quota) db_session.add(quota)
await db_session.commit() await db_session.commit()
token = create_access_token(str(user_id), "user") # Phase 7.4: bind token fgp to _TEST_USER_AGENT so get_current_user validates
# successfully when the token is used via the async_client fixture (which sends
# the same User-Agent constant).
token = create_access_token(str(user_id), "user", user_agent=_TEST_USER_AGENT)
return { return {
"user": user, "user": user,
"token": token, "token": token,
@@ -312,7 +332,8 @@ async def second_auth_user(db_session: AsyncSession):
db_session.add(quota) db_session.add(quota)
await db_session.commit() await db_session.commit()
token = create_access_token(str(user_id), "user") # Phase 7.4: bind token fgp to _TEST_USER_AGENT (matches async_client default)
token = create_access_token(str(user_id), "user", user_agent=_TEST_USER_AGENT)
return { return {
"user": user, "user": user,
"token": token, "token": token,
@@ -349,7 +370,8 @@ async def admin_user(db_session: AsyncSession):
db_session.add(quota) db_session.add(quota)
await db_session.commit() await db_session.commit()
token = create_access_token(str(user_id), "admin") # Phase 7.4: bind token fgp to _TEST_USER_AGENT (matches async_client default)
token = create_access_token(str(user_id), "admin", user_agent=_TEST_USER_AGENT)
return { return {
"user": user, "user": user,
"token": token, "token": token,
+15 -8
View File
@@ -22,6 +22,7 @@ from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from tests.test_auth_api import FakeRedis from tests.test_auth_api import FakeRedis
from tests.conftest import _TEST_USER_AGENT
# ── Minimal test app with /test/me and /test/admin routes ───────────────────── # ── Minimal test app with /test/me and /test/admin routes ─────────────────────
@@ -65,7 +66,13 @@ async def auth_client(db_session: AsyncSession):
app = make_test_app() app = make_test_app()
app.dependency_overrides[get_db] = lambda: db_session app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: # Phase 7.4: send _TEST_USER_AGENT so fgp check in get_current_user succeeds
# for tokens created with the same user-agent below.
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"User-Agent": _TEST_USER_AGENT},
) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -97,7 +104,7 @@ async def test_get_current_user_returns_user(auth_client, db_session):
from services.auth import create_access_token from services.auth import create_access_token
user = await _create_user(db_session, role="user") user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
resp = await auth_client.get( resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"} "/test/me", headers={"Authorization": f"Bearer {token}"}
@@ -124,7 +131,7 @@ async def test_get_current_user_rejects_inactive_user(auth_client, db_session):
from services.auth import create_access_token from services.auth import create_access_token
user = await _create_user(db_session, role="user", is_active=False) user = await _create_user(db_session, role="user", is_active=False)
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
resp = await auth_client.get( resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"} "/test/me", headers={"Authorization": f"Bearer {token}"}
@@ -138,7 +145,7 @@ async def test_get_current_admin_rejects_non_admin(auth_client, db_session):
from services.auth import create_access_token from services.auth import create_access_token
user = await _create_user(db_session, role="user") user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
resp = await auth_client.get( resp = await auth_client.get(
"/test/admin", headers={"Authorization": f"Bearer {token}"} "/test/admin", headers={"Authorization": f"Bearer {token}"}
@@ -153,7 +160,7 @@ async def test_get_current_admin_allows_admin(auth_client, db_session):
from services.auth import create_access_token from services.auth import create_access_token
admin_user = await _create_user(db_session, role="admin") admin_user = await _create_user(db_session, role="admin")
token = create_access_token(str(admin_user.id), "admin") token = create_access_token(str(admin_user.id), "admin", user_agent=_TEST_USER_AGENT)
resp = await auth_client.get( resp = await auth_client.get(
"/test/admin", headers={"Authorization": f"Bearer {token}"} "/test/admin", headers={"Authorization": f"Bearer {token}"}
@@ -193,7 +200,7 @@ async def test_get_current_user_rejects_token_when_iat_before_user_nbf(auth_clie
import time import time
user = await _create_user(db_session, role="user") user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
# Pre-populate Redis with a future nbf (token's iat will be < this value) # Pre-populate Redis with a future nbf (token's iat will be < this value)
future_ts = int(time.time()) + 3600 future_ts = int(time.time()) + 3600
@@ -215,7 +222,7 @@ async def test_get_current_user_allows_token_when_iat_after_user_nbf(auth_client
import time import time
user = await _create_user(db_session, role="user") user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
# Pre-populate Redis with a past nbf (token's iat will be > this value) # Pre-populate Redis with a past nbf (token's iat will be > this value)
past_ts = int(time.time()) - 3600 past_ts = int(time.time()) - 3600
@@ -238,7 +245,7 @@ async def test_get_current_user_failopen_on_redis_error(auth_client, db_session)
raise RuntimeError("simulated redis down") raise RuntimeError("simulated redis down")
user = await _create_user(db_session, role="user") user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user") token = create_access_token(str(user.id), "user", user_agent=_TEST_USER_AGENT)
# Override app.state.redis with a broken redis for this test # Override app.state.redis with a broken redis for this test
auth_client._transport.app.state.redis = _BrokenRedis() auth_client._transport.app.state.redis = _BrokenRedis()
+91 -14
View File
@@ -1,5 +1,5 @@
""" """
TDD scaffold for Phase 7.4: Token fingerprinting / token binding. Integration tests for Phase 7.4: Token fingerprinting / token binding.
Covers FGP-01..FGP-04: Covers FGP-01..FGP-04:
- FGP-01 test_fgp_match_returns_200: token issued with matching UA/lang → - FGP-01 test_fgp_match_returns_200: token issued with matching UA/lang →
@@ -12,18 +12,20 @@ Covers FGP-01..FGP-04:
- FGP-04 test_missing_headers_empty_string_binding: token issued with no - FGP-04 test_missing_headers_empty_string_binding: token issued with no
UA/lang (defaults to ""), request sent with no User-Agent / Accept-Language UA/lang (defaults to ""), request sent with no User-Agent / Accept-Language
headers → 200 (empty-string fingerprint matches). headers → 200 (empty-string fingerprint matches).
All four tests are xfail(strict=False) until Wave 1 (Plan 07.4-02) implements
the fgp claim in services/auth.py and deps/auth.py.
""" """
import base64
import uuid import uuid
import uuid as _uuid
from datetime import datetime, timezone, timedelta
import jwt as _jwt
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from tests.test_auth_api import FakeRedis from tests.test_auth_api import FakeRedis
@@ -84,32 +86,107 @@ async def _create_user(db_session, role: str = "user", is_active: bool = True):
return user return user
# ── FGP stubs (Wave 0) ──────────────────────────────────────────────────────── # ── FGP tests (Wave 1 — promoted from xfail stubs) ───────────────────────────
@pytest.mark.xfail(strict=False, reason="not implemented yet")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fgp_match_returns_200(auth_client, db_session): async def test_fgp_match_returns_200(auth_client, db_session):
"""FGP-01: token issued with matching UA/Accept-Language → 200.""" """FGP-01: token issued with matching UA/Accept-Language → 200."""
pytest.xfail("not implemented yet") from services.auth import create_access_token
user = await _create_user(db_session, role="user")
token = create_access_token(
str(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
@pytest.mark.xfail(strict=False, reason="not implemented yet")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fgp_mismatch_returns_401(auth_client, db_session): async def test_fgp_mismatch_returns_401(auth_client, db_session):
"""FGP-02: token issued for one UA, request from different UA → 401.""" """FGP-02: token issued for one UA, request from different UA → 401."""
pytest.xfail("not implemented yet") from services.auth import create_access_token
user = await _create_user(db_session, role="user")
token = create_access_token(
str(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",
"Accept-Language": "en",
},
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Token fingerprint mismatch"
@pytest.mark.xfail(strict=False, reason="not implemented yet")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_fgp_claim_allowed(auth_client, db_session): async def test_no_fgp_claim_allowed(auth_client, db_session):
"""FGP-03: token without fgp claim → 200 (migration grace period).""" """FGP-03: token without fgp claim → 200 (migration grace period)."""
pytest.xfail("not implemented yet") user = await _create_user(db_session, role="user")
# Manually craft a JWT without the "fgp" key (simulating a pre-7.4 token)
now = datetime.now(timezone.utc)
payload_no_fgp = {
"sub": str(user.id),
"role": "user",
"typ": "access",
"iat": now,
"exp": now + timedelta(minutes=15),
"jti": str(_uuid.uuid4()),
# "fgp" intentionally absent
}
private_pem = base64.b64decode(settings.jwt_private_key).decode()
token = _jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
resp = await auth_client.get(
"/test/me",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
@pytest.mark.xfail(strict=False, reason="not implemented yet")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_missing_headers_empty_string_binding(auth_client, db_session): async def test_missing_headers_empty_string_binding(auth_client, db_session):
"""FGP-04: token issued with no headers (empty-string binding), request with no headers → 200.""" """FGP-04: token issued with empty-string binding, request with empty UA → 200.
pytest.xfail("not implemented yet")
When no User-Agent or Accept-Language are provided, both the token issuance
and the validation path use empty strings. httpx sends a default User-Agent
header automatically, so we must explicitly set it to "" to reproduce the
absent-header scenario in the test client.
"""
from services.auth import create_access_token
user = await _create_user(db_session, role="user")
# Issue token with empty-string defaults (no UA, no Accept-Language)
token = create_access_token(str(user.id), "user")
# Send request with empty User-Agent and no Accept-Language;
# FastAPI will see request.headers.get("User-Agent", "") == ""
# matching the empty-string fingerprint embedded in the token.
resp = await auth_client.get(
"/test/me",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "",
},
)
assert resp.status_code == 200
+6 -1
View File
@@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
from tests.conftest import _TEST_USER_AGENT
# ── Shared auth helper ──────────────────────────────────────────────────────── # ── Shared auth helper ────────────────────────────────────────────────────────
@@ -29,6 +31,8 @@ async def _create_user_and_token(session, role: str = "user"):
"""Create a User + Quota row, return {user, token, headers}. """Create a User + Quota row, return {user, token, headers}.
Mirrors the auth_user fixture pattern from conftest.py. Mirrors the auth_user fixture pattern from conftest.py.
Phase 7.4: tokens are bound to _TEST_USER_AGENT fgp so async_client
(which sends the same User-Agent) passes fgp validation in get_current_user.
""" """
from db.models import User, Quota from db.models import User, Quota
from services.auth import hash_password, create_access_token from services.auth import hash_password, create_access_token
@@ -52,7 +56,8 @@ async def _create_user_and_token(session, role: str = "user"):
session.add(quota) session.add(quota)
await session.commit() await session.commit()
token = create_access_token(str(user_id), role) # Phase 7.4: bind fgp to _TEST_USER_AGENT (matches async_client default)
token = create_access_token(str(user_id), role, user_agent=_TEST_USER_AGENT)
return { return {
"user": user, "user": user,
"token": token, "token": token,
+9 -3
View File
@@ -300,6 +300,7 @@ async def test_cross_user_access_404(async_client, auth_user, db_session):
import uuid as _uuid import uuid as _uuid
from db.models import Document, User, Quota from db.models import Document, User, Quota
from services.auth import hash_password, create_access_token from services.auth import hash_password, create_access_token
from tests.conftest import _TEST_USER_AGENT
# Create User A's document directly via ORM # Create User A's document directly via ORM
doc_id = _uuid.uuid4() doc_id = _uuid.uuid4()
@@ -331,7 +332,8 @@ async def test_cross_user_access_404(async_client, auth_user, db_session):
db_session.add(quota_b) db_session.add(quota_b)
await db_session.commit() await db_session.commit()
token_b = create_access_token(str(user_b_id), "user") # Phase 7.4: bind fgp to _TEST_USER_AGENT (matches async_client default)
token_b = create_access_token(str(user_b_id), "user", user_agent=_TEST_USER_AGENT)
headers_b = {"Authorization": f"Bearer {token_b}"} headers_b = {"Authorization": f"Bearer {token_b}"}
# User B attempts to access User A's document — must get 404 (not 403) # User B attempts to access User A's document — must get 404 (not 403)
@@ -521,6 +523,7 @@ async def test_content_stream_share_recipient_200(async_client, auth_user, admin
import uuid as _uuid2 import uuid as _uuid2
from db.models import User, Quota from db.models import User, Quota
from services.auth import hash_password, create_access_token from services.auth import hash_password, create_access_token
from tests.conftest import _TEST_USER_AGENT
recipient_id = _uuid2.uuid4() recipient_id = _uuid2.uuid4()
recipient = User( recipient = User(
@@ -560,7 +563,8 @@ async def test_content_stream_share_recipient_200(async_client, auth_user, admin
db_session.add(share) db_session.add(share)
await db_session.commit() await db_session.commit()
recipient_token = create_access_token(str(recipient_id), "user") # Phase 7.4: bind fgp to _TEST_USER_AGENT (matches async_client default)
recipient_token = create_access_token(str(recipient_id), "user", user_agent=_TEST_USER_AGENT)
recipient_headers = {"Authorization": f"Bearer {recipient_token}"} recipient_headers = {"Authorization": f"Bearer {recipient_token}"}
resp = await async_client.get( resp = await async_client.get(
@@ -1015,6 +1019,7 @@ async def test_reclassify_cross_user_returns_404(async_client, auth_user, db_ses
import uuid as _uuid import uuid as _uuid
from db.models import Document, User, Quota from db.models import Document, User, Quota
from services.auth import hash_password, create_access_token from services.auth import hash_password, create_access_token
from tests.conftest import _TEST_USER_AGENT
# Create a document owned by auth_user # Create a document owned by auth_user
doc_id = _uuid.uuid4() doc_id = _uuid.uuid4()
@@ -1046,7 +1051,8 @@ async def test_reclassify_cross_user_returns_404(async_client, auth_user, db_ses
db_session.add(quota_b) db_session.add(quota_b)
await db_session.commit() await db_session.commit()
token_b = create_access_token(str(user_b_id), "user") # Phase 7.4: bind fgp to _TEST_USER_AGENT (matches async_client default)
token_b = create_access_token(str(user_b_id), "user", user_agent=_TEST_USER_AGENT)
headers_b = {"Authorization": f"Bearer {token_b}"} headers_b = {"Authorization": f"Bearer {token_b}"}
# User B attempts to reclassify User A's document — must get 404 # User B attempts to reclassify User A's document — must get 404
+10 -2
View File
@@ -18,6 +18,8 @@ import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import _TEST_USER_AGENT
# ── FakeRedis (needed by login endpoint) ───────────────────────────────────── # ── FakeRedis (needed by login endpoint) ─────────────────────────────────────
@@ -77,7 +79,12 @@ async def headers_client(db_session: AsyncSession):
except Exception: except Exception:
pass pass
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: # Phase 7.4: send _TEST_USER_AGENT so fgp check matches tokens from this file
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"User-Agent": _TEST_USER_AGENT},
) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -173,7 +180,8 @@ async def test_security_headers_on_authenticated_route(headers_client, db_sessio
db_session.add(quota) db_session.add(quota)
await db_session.commit() await db_session.commit()
token = create_access_token(str(user_id), "user") # Phase 7.4: bind fgp to _TEST_USER_AGENT (matches headers_client default)
token = create_access_token(str(user_id), "user", user_agent=_TEST_USER_AGENT)
resp = await headers_client.get( resp = await headers_client.get(
"/api/auth/me", "/api/auth/me",
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",