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

This commit is contained in:
curo1305
2026-06-06 17:05:56 +02:00
9 changed files with 464 additions and 22 deletions
+5
View File
@@ -31,6 +31,11 @@ REDIS_URL=redis://:changeme_redis@redis:6379/0
# JWT signing secret — generate with: python3 -c "import secrets; print(secrets.token_hex(64))" # JWT signing secret — generate with: python3 -c "import secrets; print(secrets.token_hex(64))"
SECRET_KEY=CHANGEME-replace-with-64-char-random-hex SECRET_KEY=CHANGEME-replace-with-64-char-random-hex
# ── JWT Key Pair (Phase 7.3 — ES256) ─────────────────────────────────────────
# Generated by running the Python snippet in README.md JWT Key Generation section
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=
# ── Admin Bootstrap (Phase 2 — D-04) ───────────────────────────────────────── # ── Admin Bootstrap (Phase 2 — D-04) ─────────────────────────────────────────
# First admin account created on startup if users table is empty. # First admin account created on startup if users table is empty.
# Both vars must be set; if missing, a WARNING is logged but app starts normally. # Both vars must be set; if missing, a WARNING is logged but app starts normally.
@@ -0,0 +1,188 @@
---
phase: 07.3-security-es256-algorithm-upgrade-inserted
plan: "02"
subsystem: backend/auth
tags:
- security
- jwt
- es256
- algorithm-upgrade
- startup-hook
dependency_graph:
requires:
- 07.3-01
provides:
- ES256 JWT signing at all 4 token sites
- Startup token rotation hook (idempotent)
- Operator key generation documentation
affects:
- backend/config.py
- backend/services/auth.py
- backend/main.py
- docker-compose.yml
- README.md
- .env.example
- frontend/package.json
tech_stack:
added: []
patterns:
- "base64.b64decode(settings.jwt_*_key).decode() inline at each JWT call site"
- "Idempotent startup SystemSettings upsert pattern (matching ai_config.py seed)"
- "Raw SQL bulk UPDATE inside helper with session.commit() — no Python iteration"
key_files:
created: []
modified:
- path: backend/config.py
lines: "35-38"
note: "Added refresh_token_expire_hours=16, jwt_private_key='', jwt_public_key=''"
- path: backend/services/auth.py
lines: "20,100-101,109-110,133-134,142-143"
note: "Added import base64; 4 JWT sites swapped from HS256+secret_key to ES256+inline PEM decode"
- path: backend/main.py
lines: "1-3,133-175,225-232,295"
note: "Added import logging + select; _rotate_tokens_on_algorithm_change helper; lifespan call with try/except; version 0.1.1 -> 0.1.2"
- path: backend/tests/test_auth_es256.py
lines: "39-95,109-239,244-249"
note: "Promoted 6 tests from xfail to passing (ES256-01..05 + CFG-01)"
- path: docker-compose.yml
lines: "66-67,105-106"
note: "Added JWT_PRIVATE_KEY + JWT_PUBLIC_KEY env injection to backend and celery-worker"
- path: README.md
lines: "143-144,162-181"
note: "Added JWT key vars to env table; added JWT Key Generation section with Python one-liner"
- path: .env.example
lines: "33-37"
note: "Added JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= with section header"
- path: frontend/package.json
lines: "3"
note: "Version bump 0.1.1 -> 0.1.2"
decisions:
- "Inline PEM decode at each call site (base64.b64decode inline) — no module-level PEM cache per RESEARCH.md Anti-Pattern; prevents leaked PEM via reload"
- "expire_all() in tests after _rotate_tokens_on_algorithm_change — conftest uses expire_on_commit=False; raw SQL UPDATE bypasses ORM identity map so session must be expired manually before re-query"
- "is_active=False on jwt_algorithm SystemSettings row — prevents AI provider loader from returning the metadata marker row"
metrics:
duration: "~25 minutes"
completed: "2026-06-06"
tasks_completed: 3
files_modified: 8
---
# Phase 07.3 Plan 02: ES256 Core Upgrade — JWT Sites + Startup Rotation + Operator Wiring Summary
ES256 algorithm upgrade: all 4 JWT signing/decoding sites use ECDSA P-256 with inline base64-decoded PEM keys, with idempotent startup bulk-revocation hook and full operator documentation.
---
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code | fd3f611 | backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py |
| 2 | Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests | 8d261b0 | backend/main.py, backend/tests/test_auth_es256.py |
| 3 | Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump | 0d1ab05 | docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json |
---
## Grep Gate Results (Acceptance Criteria Verified)
| Gate | Expected | Actual | Status |
|------|----------|--------|--------|
| `settings.secret_key` in services/auth.py | 0 | 0 | PASS |
| `algorithm="ES256"` in services/auth.py | 2 | 2 | PASS |
| `algorithms=["ES256"]` in services/auth.py | 2 | 2 | PASS |
| `"HS256"` in services/auth.py | 0 | 0 | PASS |
| `base64.b64decode(settings.jwt_` in services/auth.py | 4 | 4 | PASS |
| `import base64` in services/auth.py | 1 | 1 | PASS |
| `refresh_token_expire_hours: int = 16` in config.py | 1 | 1 | PASS |
| `jwt_private_key: str = ""` in config.py | 1 | 1 | PASS |
| `jwt_public_key: str = ""` in config.py | 1 | 1 | PASS |
| `async def _rotate_tokens_on_algorithm_change` in main.py | 1 | 1 | PASS |
| `await _rotate_tokens_on_algorithm_change(session)` in main.py | 1 | 1 | PASS |
| `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` in main.py | 1 | 1 | PASS |
| `provider_id == "jwt_algorithm"` in main.py | 1 | 1 | PASS |
| `is_active=False` in main.py | >= 1 | 2 | PASS |
| `ES256 rotation check skipped` in main.py | 1 | 1 | PASS |
| `JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}` in docker-compose.yml | 2 | 2 | PASS |
| `JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}` in docker-compose.yml | 2 | 2 | PASS |
| Literal base64 PEM in docker-compose.yml | 0 | 0 | PASS |
| `ec.generate_private_key(ec.SECP256R1())` in README.md | >= 1 | 1 | PASS |
| `Rotating these keys invalidates every active session` in README.md | 1 | 1 | PASS |
| `JWT_PRIVATE_KEY=` in .env.example (no value) | present | present | PASS |
| `JWT_PUBLIC_KEY=` in .env.example (no value) | present | present | PASS |
| `version="0.1.2"` in backend/main.py | 1 | 1 | PASS |
| `"version": "0.1.2"` in frontend/package.json | 1 | 1 | PASS |
| `docker compose config -q` exits 0 | 0 | 0 | PASS |
---
## Promoted Tests (xfail → PASSED)
| Test | Requirement | Before | After |
|------|-------------|--------|-------|
| test_access_token_uses_es256 | ES256-01 | XFAIL (strict) | PASSED |
| test_hs256_token_rejected | ES256-02 | XFAIL (strict) | PASSED |
| test_reset_token_uses_es256 | ES256-03 | XFAIL (strict) | PASSED |
| test_startup_rotation_revokes_tokens | ES256-04 | XFAIL (strict) | PASSED |
| test_startup_rotation_idempotent | ES256-05 | XFAIL (strict) | PASSED |
| test_settings_has_jwt_keys | CFG-01 | XFAIL (strict) | PASSED |
**Net suite delta:** `+6 PASSED`. Final count: `6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03)`.
`test_task1_models_config.py::test_settings_has_jwt_config` also passes (previously failing due to missing fields).
---
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] SQLAlchemy identity map returns stale values after raw SQL UPDATE with expire_on_commit=False**
- **Found during:** Task 2 test execution
- **Issue:** `conftest.py` creates the test `AsyncTestSession` with `expire_on_commit=False`. After `_rotate_tokens_on_algorithm_change` runs a raw SQL `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` followed by `session.commit()`, SQLAlchemy's identity map still holds the pre-commit stale state of `RefreshToken` objects (revoked=False). Re-querying via `select()` returned the stale cached object instead of issuing a fresh DB query.
- **Fix:** Added `db_session.expire_all()` in both startup rotation tests immediately after `await _rotate_tokens_on_algorithm_change(db_session)`. This invalidates the identity map cache, forcing SQLAlchemy to re-fetch from DB on the subsequent `select()`. Production code is unchanged — the issue is test isolation specific to `expire_on_commit=False`.
- **Files modified:** `backend/tests/test_auth_es256.py`
- **Commit:** 8d261b0
---
## Remaining XFAIL Tests (Plan 03)
| Test | Requirement | Reason |
|------|-------------|--------|
| test_default_ttl_16_hours | RM-01 | remember_me param not yet added to create_refresh_token |
| test_remember_me_ttl_30_days | RM-02 | same |
| test_remember_me_cookie_max_age | RM-03 | _set_refresh_cookie + LoginView.vue changes deferred to Plan 03 |
---
## Operator Handoff
Operators must generate a P-256 key pair before the backend can sign or verify JWTs:
```bash
python3 -c "
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
print(f'JWT_PRIVATE_KEY={priv}')
print(f'JWT_PUBLIC_KEY={pub}')
"
```
Paste the two output lines into `.env`. On next `docker compose up`, the startup rotation hook will bulk-revoke all existing refresh tokens (forcing all users to re-login) and record the ES256 migration marker in `system_settings`. Subsequent boots are idempotent.
---
## Self-Check: PASSED
- `backend/config.py` exists and contains the three new fields: FOUND
- `backend/services/auth.py` uses ES256 at all 4 sites, zero HS256/secret_key references: FOUND
- `backend/main.py` contains `_rotate_tokens_on_algorithm_change` at module scope: FOUND
- `docker-compose.yml` has JWT_PRIVATE_KEY and JWT_PUBLIC_KEY in both services: FOUND
- `README.md` contains key generation snippet: FOUND
- `.env.example` contains JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=: FOUND
- Commits fd3f611, 8d261b0, 0d1ab05 all exist: FOUND
- Test suite: 6 PASSED + 3 XFAILED, 0 FAILED: CONFIRMED
+25
View File
@@ -141,6 +141,8 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `SECRET_KEY` | JWT signing secret — generate with `openssl rand -hex 32` | | `SECRET_KEY` | JWT signing secret — generate with `openssl rand -hex 32` |
| `JWT_PRIVATE_KEY` | Base64-encoded PEM private key for ES256 JWT signing (required) — see [JWT Key Generation](#jwt-key-generation) |
| `JWT_PUBLIC_KEY` | Base64-encoded PEM public key for ES256 JWT verification (required) — see [JWT Key Generation](#jwt-key-generation) |
| `CLOUD_CREDS_KEY` | Master key for cloud credential encryption — generate with `openssl rand -hex 16` (pad to 32 chars) | | `CLOUD_CREDS_KEY` | Master key for cloud credential encryption — generate with `openssl rand -hex 16` (pad to 32 chars) |
| `ADMIN_EMAIL` | Bootstrap admin email | | `ADMIN_EMAIL` | Bootstrap admin email |
| `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) | | `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) |
@@ -159,6 +161,29 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
| `GOOGLE_CLIENT_ID/SECRET` | *(unset)* | Required only if using Google Drive backend | | `GOOGLE_CLIENT_ID/SECRET` | *(unset)* | Required only if using Google Drive backend |
| `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend | | `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend |
### JWT Key Generation
Phase 7.3 uses ES256 (ECDSA P-256) for JWT signing. Unlike HS256, ES256 is asymmetric — the private key signs tokens and the public key verifies them. A leaked public key cannot forge tokens.
Generate the key pair with this Python one-liner (requires `cryptography`, already in `requirements.txt`):
```bash
python3 -c "
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
print(f'JWT_PRIVATE_KEY={priv}')
print(f'JWT_PUBLIC_KEY={pub}')
"
```
Paste the two output lines into your `.env` file at the project root.
> **Warning:** Rotating these keys invalidates every active session — the startup rotation hook will bulk-revoke all refresh tokens on the next boot.
--- ---
## Development ## Development
+4
View File
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
# Auth / JWT (Phase 2) # Auth / JWT (Phase 2)
access_token_expire_minutes: int = 15 access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30 refresh_token_expire_days: int = 30
# Phase 7.3 — D-01, D-09: ES256 key pair + short-session TTL
refresh_token_expire_hours: int = 16 # default short session
jwt_private_key: str = "" # base64-encoded PEM; required in production
jwt_public_key: str = "" # base64-encoded PEM; required in production
# SMTP (Phase 2 — D-01) # SMTP (Phase 2 — D-01)
smtp_host: str = "" smtp_host: str = ""
+58 -2
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
import time import time
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -12,7 +13,7 @@ from minio import Minio
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import text from sqlalchemy import select, text
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
@@ -130,6 +131,49 @@ class CorrelationIDMiddleware:
) )
# ── ES256 startup rotation ────────────────────────────────────────────────────
async def _rotate_tokens_on_algorithm_change(session) -> None:
"""Idempotent ES256 migration (Phase 7.3 D-04/D-05).
On every boot, compares the jwt_algorithm marker row in system_settings against
'ES256'. If absent or different, bulk-revokes all active refresh tokens via raw
SQL UPDATE and upserts the marker (with is_active=False so the AI provider loader
never returns this row). No-op on second boot.
"""
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is not None and row.model_name == "ES256":
return # Already migrated — idempotent no-op
# Bulk-revoke all active refresh tokens (single raw SQL — no Python iteration)
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
logging.getLogger(__name__).info(
"ES256 startup rotation: bulk-revoked all active refresh tokens"
)
if row is None:
session.add(SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
))
else:
row.model_name = "ES256"
await session.commit()
# ── Lifespan ────────────────────────────────────────────────────────────────── # ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager @asynccontextmanager
@@ -179,6 +223,18 @@ async def lifespan(app: FastAPI):
"AI provider seed skipped (table may not exist yet): %s", _seed_exc "AI provider seed skipped (table may not exist yet): %s", _seed_exc
) )
# ES256 startup rotation (Phase 7.3 — D-04):
# If jwt_algorithm in system_settings differs from "ES256" (or is absent),
# bulk-revoke all refresh tokens and record the new algorithm.
# Wrapped in try/except so a missing table before migrations doesn't crash.
try:
async with AsyncSessionLocal() as session:
await _rotate_tokens_on_algorithm_change(session)
except Exception as _es256_exc:
logging.getLogger(__name__).warning(
"ES256 rotation check skipped (table may not exist yet): %s", _es256_exc
)
yield yield
# Shutdown: close pooled connections and Redis # Shutdown: close pooled connections and Redis
@@ -188,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.1", lifespan=lifespan) app = FastAPI(title="Document Scanner API", version="0.1.2", lifespan=lifespan)
# Rate limiter state (slowapi) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+9 -4
View File
@@ -17,6 +17,7 @@ Security invariants:
""" """
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import hmac import hmac
import logging import logging
@@ -97,7 +98,8 @@ def create_access_token(user_id: str, role: str) -> str:
"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()),
} }
return jwt.encode(payload, settings.secret_key, algorithm="HS256") private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_access_token(token: str) -> dict: def decode_access_token(token: str) -> dict:
@@ -107,7 +109,8 @@ def decode_access_token(token: str) -> dict:
tokens from being used as access tokens). tokens from being used as access tokens).
""" """
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
@@ -130,7 +133,8 @@ def create_password_reset_token(user_id: str) -> str:
"iat": now, "iat": now,
"exp": now + timedelta(seconds=3600), "exp": now + timedelta(seconds=3600),
} }
return jwt.encode(payload, settings.secret_key, algorithm="HS256") private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_password_reset_token(token: str) -> str: def decode_password_reset_token(token: str) -> str:
@@ -139,7 +143,8 @@ def decode_password_reset_token(token: str) -> str:
Returns the user_id string. Returns the user_id string.
""" """
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise ValueError("Reset token has expired") from exc raise ValueError("Reset token has expired") from exc
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
+170 -15
View File
@@ -36,35 +36,186 @@ def es256_keys(monkeypatch):
monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False) monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False)
# ── ES256 algorithm stubs ───────────────────────────────────────────────────── # ── ES256 algorithm tests ─────────────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-01: not yet implemented")
def test_access_token_uses_es256(): def test_access_token_uses_es256():
pytest.xfail("not yet implemented") """ES256-01: access token header must declare alg=ES256."""
from services.auth import create_access_token
token = create_access_token("u1", "user")
# Decode the header (first segment of the JWT)
header_b64 = token.split(".")[0]
# Add padding to make it valid base64
padding = "=" * (4 - len(header_b64) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding))
assert header["alg"] == "ES256"
@pytest.mark.xfail(strict=True, reason="ES256-02: not yet implemented")
def test_hs256_token_rejected(): def test_hs256_token_rejected():
pytest.xfail("not yet implemented") """ES256-02: an HS256 token must raise ValueError when decoded."""
import jwt as _jwt
from services.auth import decode_access_token
hs256_token = _jwt.encode(
{
"sub": "u1",
"typ": "access",
"exp": int(time.time()) + 60,
"iat": int(time.time()),
},
"any-hs256-secret",
algorithm="HS256",
)
with pytest.raises(ValueError):
decode_access_token(hs256_token)
@pytest.mark.xfail(strict=True, reason="ES256-03: not yet implemented")
def test_reset_token_uses_es256(): def test_reset_token_uses_es256():
pytest.xfail("not yet implemented") """ES256-03: password-reset token header must declare alg=ES256 and round-trips."""
from services.auth import create_password_reset_token, decode_password_reset_token
token = create_password_reset_token("u1")
header_b64 = token.split(".")[0]
padding = "=" * (4 - len(header_b64) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding))
assert header["alg"] == "ES256"
assert decode_password_reset_token(token) == "u1"
# ── Startup token rotation stubs ────────────────────────────────────────────── # ── Startup token rotation tests ──────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session): async def test_startup_rotation_revokes_tokens(db_session):
pytest.xfail("not yet implemented") """ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted."""
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select
# Create a minimal user row (required by RefreshToken FK)
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"testrotate_{user_id.hex[:8]}",
email=f"testrotate_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Insert two active refresh tokens
now = datetime.now(timezone.utc)
tok1_id = uuid.uuid4()
tok2_id = uuid.uuid4()
tok1 = RefreshToken(
id=tok1_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
tok2 = RefreshToken(
id=tok2_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok1)
db_session.add(tok2)
await db_session.flush()
# Act: no jwt_algorithm row exists yet
await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False
# (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit.
db_session.expire_all()
# Assert: both tokens are now revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id]))
)
rows = result.scalars().all()
assert len(rows) == 2
assert all(r.revoked is True for r in rows)
# Assert: jwt_algorithm row was created correctly
ss_result = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
ss_row = ss_result.scalar_one_or_none()
assert ss_row is not None
assert ss_row.model_name == "ES256"
assert ss_row.is_active is False
assert ss_row.context_chars == 0
@pytest.mark.xfail(strict=True, reason="ES256-05: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_idempotent(db_session): async def test_startup_rotation_idempotent(db_session):
pytest.xfail("not yet implemented") """ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked."""
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select, func
# Create a user
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"testidempotent_{user_id.hex[:8]}",
email=f"testidempotent_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Seed the jwt_algorithm row as already migrated
existing_marker = SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
)
db_session.add(existing_marker)
# Insert one fresh active token
now = datetime.now(timezone.utc)
tok_id = uuid.uuid4()
tok = RefreshToken(
id=tok_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok)
await db_session.flush()
# Act
await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens)
db_session.expire_all()
# Assert: the fresh token was NOT revoked (bulk update did not fire)
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == tok_id)
)
fresh_tok = result.scalar_one_or_none()
assert fresh_tok is not None
assert fresh_tok.revoked is False
# Assert: still exactly one jwt_algorithm row (no duplicate upsert)
count_result = await db_session.execute(
select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm")
)
count = count_result.scalar_one()
assert count == 1
# ── Remember-me TTL stubs ───────────────────────────────────────────────────── # ── Remember-me TTL stubs ─────────────────────────────────────────────────────
@@ -87,8 +238,12 @@ async def test_remember_me_cookie_max_age(async_client, auth_user):
pytest.xfail("not yet implemented") pytest.xfail("not yet implemented")
# ── Config field stubs ──────────────────────────────────────────────────────── # ── Config field tests ────────────────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="CFG-01: not yet implemented")
def test_settings_has_jwt_keys(): def test_settings_has_jwt_keys():
pytest.xfail("not yet implemented") """CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16."""
from config import settings
assert hasattr(settings, "jwt_private_key")
assert hasattr(settings, "jwt_public_key")
assert hasattr(settings, "refresh_token_expire_hours")
assert settings.refresh_token_expire_hours == 16
+4
View File
@@ -62,6 +62,8 @@ services:
- MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000} - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
- ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
@@ -101,6 +103,8 @@ services:
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY} - CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- PYTHONPATH=/app - PYTHONPATH=/app
labels: labels:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",