chore: merge executor worktree (worktree-agent-a9c4907ff53a1a111)

# Conflicts:
#	.env.example
#	README.md
#	backend/config.py
#	backend/main.py
#	backend/tests/test_auth_es256.py
This commit is contained in:
curo1305
2026-06-06 18:07:31 +02:00
5 changed files with 254 additions and 131 deletions
+209 -123
View File
@@ -2,6 +2,8 @@
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
@@ -11,56 +13,61 @@ from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
# ── ES256 key fixture ────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def es256_keys(monkeypatch):
"""Patch settings with a freshly generated P-256 key pair for each test."""
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()
monkeypatch.setattr("config.settings.jwt_private_key", priv, raising=False)
monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False)
"""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 algorithm tests ─────────────────────────────────────────────────────
# ── ES256-01: access token algorithm ─────────────────────────────────────────
def test_access_token_uses_es256():
"""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))
# 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 ───────────────────────────────────────────
def test_hs256_token_rejected():
"""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()),
},
{"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())},
"any-hs256-secret",
algorithm="HS256",
)
@@ -68,113 +75,111 @@ def test_hs256_token_rejected():
decode_access_token(hs256_token)
# ── ES256-03: password-reset token algorithm ──────────────────────────────────
def test_reset_token_uses_es256():
"""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))
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"
# ── Startup token rotation tests ──────────────────────────────────────────────
# ── ES256-04: startup rotation revokes tokens ─────────────────────────────────
@pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session):
"""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 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
# Create a minimal user row (required by RefreshToken FK)
user_id = uuid.uuid4()
# Set up: create a user + two RefreshToken rows with revoked=False
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",
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)
await db_session.flush()
# Insert two active refresh tokens
db_session.add(quota)
now = datetime.now(timezone.utc)
tok1_id = uuid.uuid4()
tok2_id = uuid.uuid4()
tok1 = RefreshToken(
id=tok1_id,
rt1_id = _uuid.uuid4()
rt2_id = _uuid.uuid4()
rt1 = RefreshToken(
id=rt1_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
tok2 = RefreshToken(
id=tok2_id,
rt2 = RefreshToken(
id=rt2_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok1)
db_session.add(tok2)
db_session.add(rt1)
db_session.add(rt2)
await db_session.flush()
# No jwt_algorithm row in system_settings
# Act: no jwt_algorithm row exists yet
# Act
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.
# After the helper's commit, expire the identity map so we read fresh DB state
db_session.expire_all()
# Assert: both tokens are now revoked
# Assert: both tokens revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id]))
select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id]))
)
rows = result.scalars().all()
assert len(rows) == 2
assert all(r.revoked is True for r in rows)
assert all(r.revoked for r in rows)
# Assert: jwt_algorithm row was created correctly
ss_result = await db_session.execute(
# Assert: jwt_algorithm marker row created
result2 = 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
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.asyncio
async def test_startup_rotation_idempotent(db_session):
"""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
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
# 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(),
# 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,
@@ -182,66 +187,147 @@ async def test_startup_rotation_idempotent(db_session):
api_key_enc=None,
base_url=None,
)
db_session.add(existing_marker)
db_session.add(marker)
# Insert one fresh active token
# 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)
tok_id = uuid.uuid4()
tok = RefreshToken(
id=tok_id,
rt_id = _uuid.uuid4()
rt = RefreshToken(
id=rt_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok)
db_session.add(rt)
await db_session.flush()
# Act
# Act: run rotation — should be no-op because model_name is already 'ES256'
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)
# Assert: RefreshToken still not revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == tok_id)
select(RefreshToken).where(RefreshToken.id == rt_id)
)
fresh_tok = result.scalar_one_or_none()
assert fresh_tok is not None
assert fresh_tok.revoked is False
token_row = result.scalar_one_or_none()
assert token_row is not None
assert not token_row.revoked
# 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")
# Assert: exactly one jwt_algorithm row (no duplicates)
result2 = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
count = count_result.scalar_one()
assert count == 1
markers = result2.scalars().all()
assert len(markers) == 1
# ── Remember-me TTL stubs ─────────────────────────────────────────────────────
# ── 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
# ── Config field tests ────────────────────────────────────────────────────────
# ── CFG-01 satellite: settings has jwt key fields ────────────────────────────
def test_settings_has_jwt_keys():
"""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")