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:
@@ -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))"
|
||||
SECRET_KEY=CHANGEME-replace-with-64-char-random-hex
|
||||
|
||||
# ── ES256 JWT Keypair (Phase 7.3) ─────────────────────────────────────────────
|
||||
# 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) ─────────────────────────────────────────
|
||||
# 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.
|
||||
|
||||
@@ -140,7 +140,9 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SECRET_KEY` | JWT signing secret — generate with `openssl rand -hex 32` |
|
||||
| `SECRET_KEY` | Legacy HMAC secret (kept for future use) — generate with `openssl rand -hex 32` |
|
||||
| `JWT_PRIVATE_KEY` | base64-encoded PEM PKCS8 private key for ES256 JWT signing (required) |
|
||||
| `JWT_PUBLIC_KEY` | base64-encoded PEM SubjectPublicKeyInfo public key for ES256 JWT verification (required) |
|
||||
| `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_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 |
|
||||
| `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend |
|
||||
|
||||
### JWT Key Generation
|
||||
|
||||
Phase 7.3 uses ES256 (ECDSA P-256) asymmetric signing. The private key signs tokens; the public key verifies them. A leaked public key cannot forge tokens.
|
||||
|
||||
Generate the keypair with a single Python command:
|
||||
|
||||
```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
|
||||
|
||||
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
|
||||
# Auth / JWT (Phase 2)
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
# ES256 keypair + short-session TTL (Phase 7.3 — D-01, D-09)
|
||||
refresh_token_expire_hours: int = 16 # default short session (16h workday)
|
||||
jwt_private_key: str = "" # base64-encoded PKCS8 PEM; required at runtime
|
||||
jwt_public_key: str = "" # base64-encoded SubjectPublicKeyInfo PEM; required at runtime
|
||||
|
||||
# SMTP (Phase 2 — D-01)
|
||||
smtp_host: str = ""
|
||||
|
||||
+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
|
||||
|
||||
@@ -17,6 +17,7 @@ Security invariants:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
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),
|
||||
"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:
|
||||
@@ -107,7 +109,8 @@ def decode_access_token(token: str) -> dict:
|
||||
tokens from being used as access tokens).
|
||||
"""
|
||||
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:
|
||||
raise ValueError("Token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
@@ -130,7 +133,8 @@ def create_password_reset_token(user_id: str) -> str:
|
||||
"iat": now,
|
||||
"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:
|
||||
@@ -139,7 +143,8 @@ def decode_password_reset_token(token: str) -> str:
|
||||
Returns the user_id string.
|
||||
"""
|
||||
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:
|
||||
raise ValueError("Reset token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
|
||||
@@ -49,43 +49,189 @@ def es256_keys(monkeypatch):
|
||||
# ── ES256-01: access token algorithm ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="ES256-01: not yet implemented")
|
||||
def test_access_token_uses_es256():
|
||||
pytest.xfail("not yet implemented")
|
||||
from services.auth import create_access_token
|
||||
token = create_access_token("u1", "user")
|
||||
# Decode header (first segment of JWT)
|
||||
segment = token.split(".")[0]
|
||||
# Add padding
|
||||
segment += "=" * (4 - len(segment) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(segment))
|
||||
assert header["alg"] == "ES256"
|
||||
|
||||
|
||||
# ── ES256-02: HS256 token rejected ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="ES256-02: not yet implemented")
|
||||
def test_hs256_token_rejected():
|
||||
pytest.xfail("not yet implemented")
|
||||
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)
|
||||
|
||||
|
||||
# ── ES256-03: password-reset token algorithm ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="ES256-03: not yet implemented")
|
||||
def test_reset_token_uses_es256():
|
||||
pytest.xfail("not yet implemented")
|
||||
from services.auth import create_password_reset_token, decode_password_reset_token
|
||||
token = create_password_reset_token("u1")
|
||||
segment = token.split(".")[0]
|
||||
segment += "=" * (4 - len(segment) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(segment))
|
||||
assert header["alg"] == "ES256"
|
||||
assert decode_password_reset_token(token) == "u1"
|
||||
|
||||
|
||||
# ── ES256-04: startup rotation revokes tokens ─────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_rotation_revokes_tokens(db_session):
|
||||
pytest.xfail("not yet implemented")
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import select
|
||||
from db.models import RefreshToken, SystemSettings, User, Quota
|
||||
from main import _rotate_tokens_on_algorithm_change
|
||||
from services.auth import hash_password
|
||||
|
||||
# Set up: create a user + two RefreshToken rows with revoked=False
|
||||
user_id = _uuid.uuid4()
|
||||
user = User(
|
||||
id=user_id,
|
||||
handle=f"rottest_{user_id.hex[:8]}",
|
||||
email=f"rottest_{user_id.hex[:8]}@example.com",
|
||||
password_hash=hash_password("Testpassword123!"),
|
||||
role="user",
|
||||
is_active=True,
|
||||
password_must_change=False,
|
||||
)
|
||||
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
|
||||
db_session.add(user)
|
||||
db_session.add(quota)
|
||||
now = datetime.now(timezone.utc)
|
||||
rt1_id = _uuid.uuid4()
|
||||
rt2_id = _uuid.uuid4()
|
||||
rt1 = RefreshToken(
|
||||
id=rt1_id,
|
||||
user_id=user_id,
|
||||
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
|
||||
expires_at=now + timedelta(days=1),
|
||||
revoked=False,
|
||||
)
|
||||
rt2 = RefreshToken(
|
||||
id=rt2_id,
|
||||
user_id=user_id,
|
||||
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
|
||||
expires_at=now + timedelta(days=1),
|
||||
revoked=False,
|
||||
)
|
||||
db_session.add(rt1)
|
||||
db_session.add(rt2)
|
||||
await db_session.flush()
|
||||
# No jwt_algorithm row in system_settings
|
||||
|
||||
# Act
|
||||
await _rotate_tokens_on_algorithm_change(db_session)
|
||||
# After the helper's commit, expire the identity map so we read fresh DB state
|
||||
db_session.expire_all()
|
||||
|
||||
# Assert: both tokens revoked
|
||||
result = await db_session.execute(
|
||||
select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id]))
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 2
|
||||
assert all(r.revoked for r in rows)
|
||||
|
||||
# Assert: jwt_algorithm marker row created
|
||||
result2 = await db_session.execute(
|
||||
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
|
||||
)
|
||||
marker = result2.scalar_one_or_none()
|
||||
assert marker is not None
|
||||
assert marker.model_name == "ES256"
|
||||
assert not marker.is_active
|
||||
assert marker.context_chars == 0
|
||||
|
||||
|
||||
# ── ES256-05: startup rotation is idempotent ─────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="ES256-05: not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_rotation_idempotent(db_session):
|
||||
pytest.xfail("not yet implemented")
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import select, func
|
||||
from db.models import RefreshToken, SystemSettings, User, Quota
|
||||
from main import _rotate_tokens_on_algorithm_change
|
||||
from services.auth import hash_password
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
# Set up: pre-existing jwt_algorithm marker row with model_name='ES256'
|
||||
marker_id = _uuid.uuid4()
|
||||
marker = SystemSettings(
|
||||
id=marker_id,
|
||||
provider_id="jwt_algorithm",
|
||||
model_name="ES256",
|
||||
context_chars=0,
|
||||
is_active=False,
|
||||
api_key_enc=None,
|
||||
base_url=None,
|
||||
)
|
||||
db_session.add(marker)
|
||||
|
||||
# Create a user + one fresh RefreshToken with revoked=False
|
||||
user_id = _uuid.uuid4()
|
||||
user = User(
|
||||
id=user_id,
|
||||
handle=f"idem_{user_id.hex[:8]}",
|
||||
email=f"idem_{user_id.hex[:8]}@example.com",
|
||||
password_hash=hash_password("Testpassword123!"),
|
||||
role="user",
|
||||
is_active=True,
|
||||
password_must_change=False,
|
||||
)
|
||||
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
|
||||
db_session.add(user)
|
||||
db_session.add(quota)
|
||||
now = datetime.now(timezone.utc)
|
||||
rt_id = _uuid.uuid4()
|
||||
rt = RefreshToken(
|
||||
id=rt_id,
|
||||
user_id=user_id,
|
||||
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
|
||||
expires_at=now + timedelta(days=1),
|
||||
revoked=False,
|
||||
)
|
||||
db_session.add(rt)
|
||||
await db_session.flush()
|
||||
|
||||
# Act: run rotation — should be no-op because model_name is already 'ES256'
|
||||
await _rotate_tokens_on_algorithm_change(db_session)
|
||||
|
||||
# Assert: RefreshToken still not revoked
|
||||
result = await db_session.execute(
|
||||
select(RefreshToken).where(RefreshToken.id == rt_id)
|
||||
)
|
||||
token_row = result.scalar_one_or_none()
|
||||
assert token_row is not None
|
||||
assert not token_row.revoked
|
||||
|
||||
# Assert: exactly one jwt_algorithm row (no duplicates)
|
||||
result2 = await db_session.execute(
|
||||
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
|
||||
)
|
||||
markers = result2.scalars().all()
|
||||
assert len(markers) == 1
|
||||
|
||||
|
||||
# ── RM-01: default TTL is 16 hours ───────────────────────────────────────────
|
||||
@@ -118,6 +264,9 @@ async def test_remember_me_cookie_max_age(async_client, auth_user):
|
||||
# ── CFG-01 satellite: settings has jwt key fields ────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="CFG-01: not yet implemented")
|
||||
def test_settings_has_jwt_keys():
|
||||
pytest.xfail("not yet implemented")
|
||||
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
|
||||
|
||||
@@ -62,6 +62,8 @@ services:
|
||||
- MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
|
||||
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
|
||||
@@ -101,6 +103,8 @@ services:
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
|
||||
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
|
||||
- PYTHONDONTWRITEBYTECODE=1
|
||||
- PYTHONPATH=/app
|
||||
labels:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "document-scanner-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user