- backend/config.py: add refresh_token_expire_hours=16, jwt_private_key, jwt_public_key - backend/services/auth.py: add import base64; all 4 JWT sites use ES256 with inline PEM decode; zero HS256/secret_key references - backend/tests/test_auth_es256.py: promote ES256-01..03 + CFG-01 tests from xfail to passing
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""
|
|
TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation,
|
|
and remember_me session TTL — all stubs xfail strict=True until promoted.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# ── ES256 algorithm tests ─────────────────────────────────────────────────────
|
|
|
|
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))
|
|
assert header["alg"] == "ES256"
|
|
|
|
|
|
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()),
|
|
},
|
|
"any-hs256-secret",
|
|
algorithm="HS256",
|
|
)
|
|
with pytest.raises(ValueError):
|
|
decode_access_token(hs256_token)
|
|
|
|
|
|
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))
|
|
assert header["alg"] == "ES256"
|
|
assert decode_password_reset_token(token) == "u1"
|
|
|
|
|
|
# ── Startup token rotation stubs ──────────────────────────────────────────────
|
|
|
|
@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")
|
|
|
|
|
|
@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")
|
|
|
|
|
|
# ── Remember-me TTL stubs ─────────────────────────────────────────────────────
|
|
|
|
@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")
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@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")
|
|
|
|
|
|
# ── Config field tests ────────────────────────────────────────────────────────
|
|
|
|
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")
|
|
assert hasattr(settings, "refresh_token_expire_hours")
|
|
assert settings.refresh_token_expire_hours == 16
|