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:
+58
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user