From 99f55825aac4e9d0de064ae61e44e0bf832284b3 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:13:50 +0200 Subject: [PATCH 1/4] =?UTF-8?q?test(07.3-01):=20Wave=200=20Nyquist=20scaff?= =?UTF-8?q?old=20=E2=80=94=209=20xfail=20stubs=20for=20ES256=20+=20remembe?= =?UTF-8?q?r=5Fme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create test_auth_es256.py with 9 xfail(strict=True) stubs: ES256-01..05, RM-01..03, CFG-01 - Add es256_keys(autouse) fixture using cryptography P-256 keygen + monkeypatch with raising=False - Extend test_settings_has_jwt_config to assert refresh_token_expire_hours==16 + jwt key fields --- backend/tests/test_auth_es256.py | 123 ++++++++++++++++++++++ backend/tests/test_task1_models_config.py | 4 + 2 files changed, 127 insertions(+) create mode 100644 backend/tests/test_auth_es256.py diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py new file mode 100644 index 0000000..8e0567f --- /dev/null +++ b/backend/tests/test_auth_es256.py @@ -0,0 +1,123 @@ +""" +TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, +and remember_me session TTL — all stubs xfail strict=True until promoted. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import time +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +import pytest_asyncio + + +# ── ES256 key fixture ──────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def es256_keys(monkeypatch): + """Generate a throw-away P-256 keypair and monkeypatch it into settings. + + raising=False: settings fields do not exist until Plan 02 adds them; + this fixture must not error before that. + """ + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import serialization + + private_key = ec.generate_private_key(ec.SECP256R1()) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + private_b64 = base64.b64encode(private_pem).decode() + public_b64 = base64.b64encode(public_pem).decode() + + import config + monkeypatch.setattr(config.settings, "jwt_private_key", private_b64, raising=False) + monkeypatch.setattr(config.settings, "jwt_public_key", public_b64, raising=False) + + +# ── 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") + + +# ── 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") + + +# ── 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") + + +# ── 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") + + +# ── 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") + + +# ── RM-01: default TTL is 16 hours ─────────────────────────────────────────── + + +@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented") +@pytest.mark.asyncio +async def test_default_ttl_16_hours(async_client, db_session, auth_user): + pytest.xfail("not yet implemented") + + +# ── RM-02: remember_me TTL is 30 days ──────────────────────────────────────── + + +@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented") +@pytest.mark.asyncio +async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): + pytest.xfail("not yet implemented") + + +# ── RM-03: cookie Max-Age values ───────────────────────────────────────────── + + +@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented") +@pytest.mark.asyncio +async def test_remember_me_cookie_max_age(async_client, auth_user): + pytest.xfail("not yet implemented") + + +# ── 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") diff --git a/backend/tests/test_task1_models_config.py b/backend/tests/test_task1_models_config.py index c833ff0..2144ec8 100644 --- a/backend/tests/test_task1_models_config.py +++ b/backend/tests/test_task1_models_config.py @@ -29,6 +29,10 @@ def test_settings_has_jwt_config(): assert settings.access_token_expire_minutes == 15 assert hasattr(settings, "refresh_token_expire_days") assert settings.refresh_token_expire_days == 30 + assert hasattr(settings, "refresh_token_expire_hours") + assert settings.refresh_token_expire_hours == 16 + assert hasattr(settings, "jwt_private_key") + assert hasattr(settings, "jwt_public_key") def test_settings_has_smtp_config(): From 8be792ab4c43b9c688659cba52b3c12fa0addf3c Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:19:50 +0200 Subject: [PATCH 2/4] feat(07.3-02): ES256 JWT algorithm upgrade + startup rotation hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 5 + README.md | 27 ++++- backend/config.py | 4 + backend/main.py | 60 ++++++++++- backend/services/auth.py | 13 ++- backend/tests/test_auth_es256.py | 173 ++++++++++++++++++++++++++++--- docker-compose.yml | 4 + frontend/package.json | 2 +- 8 files changed, 268 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 65da9ae..d5d9b73 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 4a0b5e0..6e85b30 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/config.py b/backend/config.py index 0844e33..35eaf34 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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 = "" diff --git a/backend/main.py b/backend/main.py index 26c9c5f..7d1486b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/services/auth.py b/backend/services/auth.py index a51da1b..2ddf3bf 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -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: diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py index 8e0567f..fbcb621 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3c625d7..3ba7f4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/package.json b/frontend/package.json index 91a6092..b18eb21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "document-scanner-frontend", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", From 9cc11b5446ae710f8ab10552f24fa8fa0ed9b286 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:21:14 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(07.3-03):=20backend=20remember=5Fme=20?= =?UTF-8?q?=E2=80=94=20TTL=20split=20+=20cookie=20Max-Age=20+=203=20promot?= =?UTF-8?q?ed=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/auth.py: create_refresh_token gains remember_me=False param; selects 16h or 30d TTL (D-09, D-10, D-11) - api/auth.py: LoginRequest.remember_me bool field; _set_refresh_cookie remember_me param for conditional max_age; login handler threads remember_me through both calls (D-11, RM-03) - test_auth_es256.py: promote RM-01, RM-02, RM-03 stubs — all 9 phase tests now PASSED --- backend/api/auth.py | 22 +++++++--- backend/services/auth.py | 14 +++++- backend/tests/test_auth_es256.py | 75 +++++++++++++++++++++++++++++--- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 364eebb..31bde07 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -59,6 +59,7 @@ class LoginRequest(BaseModel): password: str totp_code: Optional[str] = None backup_code: Optional[str] = None + remember_me: bool = False class ChangePasswordRequest(BaseModel): @@ -68,8 +69,19 @@ class ChangePasswordRequest(BaseModel): # ── Helper: set httpOnly refresh cookie ────────────────────────────────────── -def _set_refresh_cookie(response: Response, raw_token: str) -> None: - """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" +def _set_refresh_cookie( + response: Response, raw_token: str, remember_me: bool = False +) -> None: + """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint). + + remember_me=False (default): Max-Age = refresh_token_expire_hours * 3600 (16h, D-11, RM-03) + remember_me=True: Max-Age = refresh_token_expire_days * 86400 (30d, D-11, RM-03) + """ + max_age = ( + settings.refresh_token_expire_days * 86400 + if remember_me + else settings.refresh_token_expire_hours * 3600 + ) response.set_cookie( key="refresh_token", value=raw_token, @@ -77,7 +89,7 @@ def _set_refresh_cookie(response: Response, raw_token: str) -> None: secure=True, samesite="strict", path="/api/auth/refresh", - max_age=settings.refresh_token_expire_days * 86400, + max_age=max_age, ) @@ -276,8 +288,8 @@ async def login( # Issue tokens access_token = auth_service.create_access_token(str(user.id), user.role) - raw_refresh = await auth_service.create_refresh_token(session, user.id) - _set_refresh_cookie(response, raw_refresh) + 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) # D-13: login success event await write_audit_log( diff --git a/backend/services/auth.py b/backend/services/auth.py index 2ddf3bf..a2bbca1 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -157,20 +157,30 @@ def decode_password_reset_token(token: str) -> str: # ── Refresh token lifecycle ───────────────────────────────────────────────────── -async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: +async def create_refresh_token( + session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False +) -> str: """Insert a new RefreshToken row and return the raw (unhashed) token string. The raw token is returned to the caller and set as an httpOnly cookie. Only the SHA-256 hash is stored in the database. + + remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10) + remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11) """ raw = secrets.token_urlsafe(32) token_hash = hashlib.sha256(raw.encode()).hexdigest() now = datetime.now(timezone.utc) + ttl = ( + timedelta(days=settings.refresh_token_expire_days) + if remember_me + else timedelta(hours=settings.refresh_token_expire_hours) + ) row = RefreshToken( id=uuid.uuid4(), user_id=user_id, token_hash=token_hash, - expires_at=now + timedelta(days=settings.refresh_token_expire_days), + expires_at=now + ttl, revoked=False, ) session.add(row) diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py index fbcb621..46f89ae 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -234,31 +234,94 @@ async def test_startup_rotation_idempotent(db_session): assert len(markers) == 1 +# ── RM helpers ─────────────────────────────────────────────────────────────── + + +async def _do_login(async_client, auth_user, remember_me: bool = False) -> dict: + """POST /api/auth/login for the auth_user and return the response.""" + from tests.test_auth_api import FakeRedis + from main import app + app.state.redis = FakeRedis() + resp = await async_client.post( + "/api/auth/login", + json={ + "email": auth_user["user"].email, + "password": "Testpassword123!", + "remember_me": remember_me, + }, + ) + return resp + + # ── RM-01: default TTL is 16 hours ─────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented") @pytest.mark.asyncio async def test_default_ttl_16_hours(async_client, db_session, auth_user): - pytest.xfail("not yet implemented") + from datetime import datetime, timezone, timedelta + from sqlalchemy import select + from db.models import RefreshToken + + resp = await _do_login(async_client, auth_user, remember_me=False) + assert resp.status_code == 200 + + uid = auth_user["user"].id + result = await db_session.execute( + select(RefreshToken) + .where(RefreshToken.user_id == uid) + .order_by(RefreshToken.id.desc()) + ) + row = result.scalars().first() + assert row is not None + + now = datetime.now(timezone.utc) + delta = row.expires_at.replace(tzinfo=timezone.utc) - now + assert timedelta(hours=15, minutes=30) < delta < timedelta(hours=16, minutes=30) # ── RM-02: remember_me TTL is 30 days ──────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented") @pytest.mark.asyncio async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): - pytest.xfail("not yet implemented") + from datetime import datetime, timezone, timedelta + from sqlalchemy import select + from db.models import RefreshToken + + resp = await _do_login(async_client, auth_user, remember_me=True) + assert resp.status_code == 200 + + uid = auth_user["user"].id + result = await db_session.execute( + select(RefreshToken) + .where(RefreshToken.user_id == uid) + .order_by(RefreshToken.id.desc()) + ) + row = result.scalars().first() + assert row is not None + + now = datetime.now(timezone.utc) + delta = row.expires_at.replace(tzinfo=timezone.utc) - now + assert timedelta(days=29, hours=23) < delta < timedelta(days=30, hours=1) # ── RM-03: cookie Max-Age values ───────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented") @pytest.mark.asyncio async def test_remember_me_cookie_max_age(async_client, auth_user): - pytest.xfail("not yet implemented") + # Default (no remember_me): Max-Age = 16 * 3600 = 57600 + resp_short = await _do_login(async_client, auth_user, remember_me=False) + assert resp_short.status_code == 200 + # Parse raw Set-Cookie header for Max-Age + set_cookie_short = resp_short.headers.get("set-cookie", "") + assert "Max-Age=57600" in set_cookie_short + + # With remember_me=True: Max-Age = 30 * 86400 = 2592000 + resp_long = await _do_login(async_client, auth_user, remember_me=True) + assert resp_long.status_code == 200 + set_cookie_long = resp_long.headers.get("set-cookie", "") + assert "Max-Age=2592000" in set_cookie_long # ── CFG-01 satellite: settings has jwt key fields ──────────────────────────── From 21e5d27c904dd4b39e87664bcae49db7a22c93b9 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:23:02 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat(07.3-03):=20frontend=20remember=5Fme?= =?UTF-8?q?=20=E2=80=94=20checkbox=20+=20ref=20threading=20+=20store=20pas?= =?UTF-8?q?s-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginView.vue: add rememberMe ref(false) + "Stay signed in for 30 days" checkbox in password step; thread rememberMe.value through all 3 submit handlers (submitPassword, submitTotp, submitBackupCode) (D-12) - stores/auth.js: login() forwards options.rememberMe as remember_me in api.login body (D-12) - api/client.js: no change needed — already forwards full body verbatim - Frontend build passes (vite build exits 0) --- frontend/src/stores/auth.js | 1 + frontend/src/views/auth/LoginView.vue | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index f4300a9..3da61b9 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -66,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => { password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null, + remember_me: options.rememberMe ?? false, }) if (data.requires_totp) { diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 1a28b62..b559433 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -27,6 +27,17 @@ /> + +
+ + +
+