chore: merge executor worktree (worktree-agent-ad0ae14e8e88d89cf)
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))"
|
# 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
Reference in New Issue
Block a user