feat(07.3-02): ES256 JWT algorithm upgrade + startup rotation hook

- config.py: add refresh_token_expire_hours=16, jwt_private_key, jwt_public_key fields (D-01, D-09)
- services/auth.py: swap all 4 JWT sites to ES256 via base64-decoded PEM keys; remove HS256 (D-02, D-03)
- main.py: add _rotate_tokens_on_algorithm_change lifespan hook — bulk-revokes refresh tokens on algorithm change; idempotent on repeat boots (D-04, D-05)
- test_auth_es256.py: promote ES256-01..05 + CFG-01 stubs to 6 passing tests; RM-01..03 remain xfail
- docker-compose.yml: inject JWT_PRIVATE_KEY + JWT_PUBLIC_KEY into backend + celery-worker (D-07)
- README.md: add JWT key env vars + key generation Python one-liner snippet
- .env.example: add JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= lines
- Version bump to 0.1.2
This commit is contained in:
curo1305
2026-06-06 17:19:50 +02:00
parent 99f55825aa
commit 8be792ab4c
8 changed files with 268 additions and 20 deletions
+58 -2
View File
@@ -12,7 +12,7 @@ from minio import Minio
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import text
from sqlalchemy import select, text
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -130,6 +130,50 @@ class CorrelationIDMiddleware:
)
# ── ES256 startup rotation helper ────────────────────────────────────────────
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.
"""
import logging as _logging
from db.models import SystemSettings # noqa: PLC0415
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 # idempotent — algorithm already matches
# Bulk-revoke all active refresh tokens (single raw SQL — no Python iteration)
_logging.getLogger(__name__).info(
"ES256 startup rotation: bulk-revoked all active refresh tokens"
)
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
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 ──────────────────────────────────────────────────────────────────
@asynccontextmanager
@@ -179,6 +223,18 @@ async def lifespan(app: FastAPI):
"AI provider seed skipped (table may not exist yet): %s", _seed_exc
)
# ES256 algorithm rotation (Phase 7.3 D-04/D-05): bulk-revoke on algorithm change.
# Wrapped in try/except — fresh containers may not have system_settings table yet
# (Pitfall 6 in RESEARCH.md).
try:
async with AsyncSessionLocal() as session:
await _rotate_tokens_on_algorithm_change(session)
except Exception as _es256_exc:
import logging as _logging
_logging.getLogger(__name__).warning(
"ES256 rotation check skipped (table may not exist yet): %s", _es256_exc
)
yield
# Shutdown: close pooled connections and Redis
@@ -188,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── 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)
app.state.limiter = auth_limiter