Scaffold FastAPI + MySQL + Keycloak service with devcontainer

Sets up the project skeleton:

- FastAPI app factory with lifespan, request-id middleware, and RFC 9457
  problem+json error handlers
- Async SQLAlchemy 2.0 over MySQL (asyncmy), with a constraint naming
  convention in place before the first migration and async Alembic
- Keycloak as a pure resource server: OIDC discovery, cached JWKS with
  rotation-aware refresh, and require_roles dependencies
- Devcontainer running MySQL 8.4 and Keycloak 26.7 as compose siblings,
  with the realm (clients, roles, test users) imported on first boot
- Test suite covering the endpoints plus the token validator itself,
  exercised against a locally generated RSA keypair
- uv packaging, ruff, mypy --strict, pre-commit, Gitea CI, prod Dockerfile

Two Keycloak-in-containers traps are handled explicitly and documented in
the README: the issuer/internal-URL split (the browser sees localhost:8080,
the API sees keycloak:8080) and the audience mapper that stops Keycloak
issuing tokens with aud=account.

The devices resource is a placeholder proving the routing -> auth -> ORM ->
migration path end to end; replace it with the real domain.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
gnickensandClaude Opus 5 committed 2026-09-10 12:46:52 -04:00
commit 0526d34e42
52 files changed
+4433

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+154
View File
@@ -0,0 +1,154 @@
"""Token-forging fixtures for exercising the validator against real crypto.
A locally generated RSA keypair stands in for Keycloak's realm keys, and the
provider's JWKS fetch is redirected at it. That makes it possible to mint
tokens that are genuinely signed -- and genuinely wrong in one specific way per
test -- without a running Keycloak.
"""
from __future__ import annotations
import datetime as dt
import json
from collections.abc import Callable, Iterator
from typing import Any
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.utils import base64url_encode
from v2x_server.core.config import Settings
TEST_KID = "test-key-1"
@pytest.fixture(scope="session")
def rsa_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture(scope="session")
def jwks(rsa_key: rsa.RSAPrivateKey) -> dict[str, Any]:
"""The public half, in the JWKS shape Keycloak would serve."""
public_numbers = rsa_key.public_key().public_numbers()
def _b64(value: int) -> str:
length = (value.bit_length() + 7) // 8
return base64url_encode(value.to_bytes(length, "big")).decode()
return {
"keys": [
{
"kty": "RSA",
"kid": TEST_KID,
"use": "sig",
"alg": "RS256",
"n": _b64(public_numbers.n),
"e": _b64(public_numbers.e),
}
]
}
@pytest.fixture
def auth_settings() -> Settings:
return Settings(
app_env="test",
keycloak_issuer="https://idp.test/realms/v2x",
keycloak_internal_url="https://idp.test",
keycloak_realm="v2x",
keycloak_audience="v2x-api",
)
@pytest.fixture
def make_token(rsa_key: rsa.RSAPrivateKey, auth_settings: Settings) -> Callable[..., str]:
"""Mint a signed token, overriding any claim or header field per test."""
def _make(
*,
kid: str | None = TEST_KID,
algorithm: str = "RS256",
key: Any = None,
expires_in: int = 300,
drop: tuple[str, ...] = (),
**claim_overrides: Any,
) -> str:
now = dt.datetime.now(tz=dt.UTC)
claims: dict[str, Any] = {
"sub": "11111111-1111-1111-1111-111111111111",
"iss": auth_settings.keycloak_issuer,
"aud": auth_settings.keycloak_audience,
"iat": now,
"exp": now + dt.timedelta(seconds=expires_in),
"preferred_username": "vera",
"email": "[email protected]",
"scope": "openid profile email",
"realm_access": {"roles": ["viewer"]},
"resource_access": {"v2x-api": {"roles": ["device.read"]}},
}
claims.update(claim_overrides)
for claim in drop:
claims.pop(claim, None)
headers = {"kid": kid} if kid else {}
return jwt.encode(
claims,
key if key is not None else rsa_key,
algorithm=algorithm,
headers=headers,
)
return _make
@pytest.fixture
def unsigned_token(auth_settings: Settings) -> str:
"""An `alg: none` token -- the classic downgrade attempt."""
now = dt.datetime.now(tz=dt.UTC)
header = base64url_encode(
json.dumps({"alg": "none", "typ": "JWT", "kid": TEST_KID}).encode()
).decode()
payload = base64url_encode(
json.dumps(
{
"sub": "attacker",
"iss": auth_settings.keycloak_issuer,
"aud": auth_settings.keycloak_audience,
"iat": int(now.timestamp()),
"exp": int((now + dt.timedelta(minutes=5)).timestamp()),
"realm_access": {"roles": ["admin"]},
}
).encode()
).decode()
return f"{header}.{payload}."
@pytest.fixture
def provider(auth_settings: Settings, jwks: dict[str, Any]) -> Iterator[Any]:
"""An OIDCProvider whose network calls are served from the fixtures above."""
import httpx
from v2x_server.auth.keycloak import OIDCProvider
certs_url = (
f"{auth_settings.keycloak_internal_url}"
f"/realms/{auth_settings.keycloak_realm}/protocol/openid-connect/certs"
)
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
json={
"issuer": auth_settings.keycloak_issuer,
"jwks_uri": certs_url,
},
)
if url.endswith("/protocol/openid-connect/certs"):
return httpx.Response(200, json=jwks)
return httpx.Response(404)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
yield OIDCProvider(auth_settings, client)
+170
View File
@@ -0,0 +1,170 @@
"""The validator must reject every category of bad token, not just expired ones.
Without these, `get_current_principal` could be accepting anything and the
endpoint tests -- which override authentication -- would never notice.
"""
from __future__ import annotations
import datetime as dt
from collections.abc import Callable
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from v2x_server.auth.keycloak import OIDCProvider, TokenError
from v2x_server.auth.principal import Principal
async def test_accepts_a_valid_token(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
claims = await provider.decode(make_token())
assert claims["sub"] == "11111111-1111-1111-1111-111111111111"
assert claims["preferred_username"] == "vera"
async def test_rejects_expired_token(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
# Beyond the 30s leeway.
with pytest.raises(TokenError):
await provider.decode(make_token(expires_in=-120))
async def test_rejects_wrong_issuer(provider: OIDCProvider, make_token: Callable[..., str]) -> None:
with pytest.raises(TokenError):
await provider.decode(make_token(iss="https://evil.test/realms/v2x"))
async def test_rejects_wrong_audience(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
# This is the failure Keycloak produces by default: without an audience
# mapper the token's aud is "account", not the API's client id.
with pytest.raises(TokenError):
await provider.decode(make_token(aud="account"))
async def test_rejects_unknown_key_id(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
with pytest.raises(TokenError):
await provider.decode(make_token(kid="rotated-away"))
async def test_rejects_missing_key_id(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
with pytest.raises(TokenError, match="no kid"):
await provider.decode(make_token(kid=None))
async def test_rejects_alg_none(provider: OIDCProvider, unsigned_token: str) -> None:
with pytest.raises(TokenError):
await provider.decode(unsigned_token)
async def test_rejects_token_signed_by_another_key(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(TokenError):
await provider.decode(make_token(key=attacker_key))
async def test_rejects_tampered_payload(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
header, payload, signature = make_token().split(".")
# Flip a character in the payload; the signature no longer covers it.
mutated = payload[:-2] + ("A" if payload[-2] != "A" else "B") + payload[-1]
with pytest.raises(TokenError):
await provider.decode(f"{header}.{mutated}.{signature}")
async def test_rejects_garbage(provider: OIDCProvider) -> None:
with pytest.raises(TokenError):
await provider.decode("not-a-jwt")
@pytest.mark.parametrize("claim", ["sub", "exp", "iat", "iss", "aud"])
async def test_rejects_token_missing_a_required_claim(
provider: OIDCProvider, make_token: Callable[..., str], claim: str
) -> None:
with pytest.raises(TokenError):
await provider.decode(make_token(drop=(claim,)))
class TestPrincipalMapping:
"""Claim shape -> Principal. The one place that knows Keycloak's layout."""
def test_maps_realm_and_client_roles(self) -> None:
principal = Principal.from_claims(
{
"sub": "abc",
"preferred_username": "vera",
"email": "[email protected]",
"realm_access": {"roles": ["viewer", "operator"]},
"resource_access": {"v2x-api": {"roles": ["device.read"]}},
"scope": "openid profile",
},
client_id="v2x-api",
)
assert principal.realm_roles == frozenset({"viewer", "operator"})
assert principal.client_roles == frozenset({"device.read"})
assert principal.roles == frozenset({"viewer", "operator", "device.read"})
assert principal.has_any_role("operator", "admin")
assert not principal.has_all_roles("operator", "admin")
def test_tolerates_absent_role_blocks(self) -> None:
principal = Principal.from_claims({"sub": "abc"}, client_id="v2x-api")
assert principal.roles == frozenset()
assert principal.username is None
def test_ignores_roles_for_other_clients(self) -> None:
principal = Principal.from_claims(
{
"sub": "abc",
"resource_access": {"some-other-client": {"roles": ["admin"]}},
},
client_id="v2x-api",
)
assert principal.client_roles == frozenset()
async def test_unknown_kid_triggers_a_refresh_once_the_floor_has_passed(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
"""Key rotation must be survivable without restarting the process."""
await provider.decode(make_token()) # warm the cache
before = provider._jwks_fetched_at
# Age the cache past the minimum refresh interval.
provider._jwks_fetched_at -= 3600
with pytest.raises(TokenError):
await provider.decode(make_token(kid="unknown-kid"))
# A refetch happened: the timestamp moved forward again.
assert provider._jwks_fetched_at > before - 3600
async def test_unknown_kid_refresh_is_rate_limited(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
"""A flood of junk tokens must not become load amplification on Keycloak."""
await provider.decode(make_token())
with pytest.raises(TokenError, match="rate-limited"):
await provider.decode(make_token(kid="unknown-kid"))
async def test_clock_skew_within_leeway_is_accepted(
provider: OIDCProvider, make_token: Callable[..., str]
) -> None:
"""A token issued a few seconds in the future should still pass."""
future = dt.datetime.now(tz=dt.UTC) + dt.timedelta(seconds=10)
claims = await provider.decode(make_token(iat=future, nbf=future))
assert claims["sub"]
+150
View File
@@ -0,0 +1,150 @@
"""Shared fixtures.
Tests run against a real MySQL database (`v2x_test`), never SQLite: this project
relies on MySQL types, collation and constraint behaviour, and a SQLite-backed
suite would happily pass while production broke.
Isolation strategy: each test runs inside an outer transaction that is rolled
back afterwards, so tests share one migrated schema without sharing data.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import AbstractAsyncContextManager
from typing import Any
import pytest
from alembic import command
from alembic.config import Config
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker
from v2x_server.auth.deps import get_current_principal
from v2x_server.auth.principal import Principal
from v2x_server.core.config import Settings
from v2x_server.db.session import create_engine, get_session
from v2x_server.main import create_app
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture(scope="session")
def settings() -> Settings:
base = Settings()
return base.model_copy(
update={
"app_env": "test",
"database_url": base.test_database_url,
"cors_origins": [],
}
)
@pytest.fixture(scope="session")
def _migrated_database(settings: Settings) -> Iterator[None]:
"""Bring `v2x_test` to head once per session."""
config = Config(os.path.join(PROJECT_ROOT, "alembic.ini"))
config.set_main_option("script_location", os.path.join(PROJECT_ROOT, "migrations"))
os.environ["ALEMBIC_DATABASE_URL"] = settings.database_url
command.upgrade(config, "head")
yield
os.environ.pop("ALEMBIC_DATABASE_URL", None)
@pytest.fixture
async def db_connection(
settings: Settings, _migrated_database: None
) -> AsyncIterator[AsyncConnection]:
"""A connection with an open outer transaction, rolled back after the test."""
engine = create_engine(settings)
async with engine.connect() as connection:
transaction = await connection.begin()
try:
yield connection
finally:
await transaction.rollback()
await engine.dispose()
@pytest.fixture
async def db_session(db_connection: AsyncConnection) -> AsyncIterator[AsyncSession]:
"""Session bound to the test's connection, joined to its outer transaction.
`join_transaction_mode="create_savepoint"` lets application code call
commit() normally -- it releases a savepoint rather than committing the
outer transaction, so the rollback above still wipes everything.
"""
factory = async_sessionmaker(
bind=db_connection,
expire_on_commit=False,
join_transaction_mode="create_savepoint",
)
async with factory() as session:
yield session
class LifespanRunner:
"""Minimal async context manager that drives an app's lifespan events."""
def __init__(self, app: FastAPI) -> None:
self._app = app
self._context: AbstractAsyncContextManager[None] | None = None
async def __aenter__(self) -> None:
self._context = self._app.router.lifespan_context(self._app)
await self._context.__aenter__()
async def __aexit__(self, *exc_info: Any) -> None:
assert self._context is not None
await self._context.__aexit__(*exc_info)
def make_principal(*roles: str, username: str = "test-user") -> Principal:
return Principal(
subject="00000000-0000-0000-0000-000000000001",
username=username,
email=f"{username}@example.com",
realm_roles=frozenset(roles),
)
@pytest.fixture
async def app(settings: Settings, db_session: AsyncSession) -> AsyncIterator[FastAPI]:
"""App wired to the test session; auth left unauthenticated by default."""
application = create_app(settings)
async def _override_session() -> AsyncIterator[AsyncSession]:
yield db_session
application.dependency_overrides[get_session] = _override_session
yield application
application.dependency_overrides.clear()
@pytest.fixture
def as_user(app: FastAPI) -> Callable[..., Principal]:
"""Authenticate subsequent requests as a principal holding `roles`."""
def _apply(*roles: str, username: str = "test-user") -> Principal:
principal = make_principal(*roles, username=username)
app.dependency_overrides[get_current_principal] = lambda: principal
return principal
return _apply
@pytest.fixture
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
"""In-process HTTP client. No live server, but the full middleware stack."""
transport = ASGITransport(app=app)
# LifespanRunner is what builds the engine and OIDC provider, so app.state
# matches production rather than being half-initialised.
async with (
LifespanRunner(app),
AsyncClient(transport=transport, base_url="http://test") as http_client,
):
yield http_client
+161
View File
@@ -0,0 +1,161 @@
"""Endpoint behaviour: authorization gates, CRUD, and error shapes."""
from __future__ import annotations
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from v2x_server.auth.principal import Principal
PREFIX = "/api/v1/devices"
AsUser = Callable[..., Principal]
def _payload(**overrides: object) -> dict[str, object]:
body: dict[str, object] = {
"name": "roadside-unit-1",
"serial": "RSU-0001",
"status": "active",
}
body.update(overrides)
return body
class TestAuthorization:
async def test_anonymous_request_is_401(self, client: AsyncClient) -> None:
response = await client.get(PREFIX)
assert response.status_code == 401
assert response.headers["www-authenticate"].startswith("Bearer")
async def test_authenticated_without_role_is_403(
self, client: AsyncClient, as_user: AsUser
) -> None:
# Authenticated, but holds no role this API recognises. 403 rather than
# 401: retrying with the same token will never help.
as_user()
response = await client.get(PREFIX)
assert response.status_code == 403
async def test_viewer_can_read(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.get(PREFIX)
assert response.status_code == 200
async def test_viewer_cannot_write(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.post(PREFIX, json=_payload())
assert response.status_code == 403
async def test_operator_can_write(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
response = await client.post(PREFIX, json=_payload())
assert response.status_code == 201
async def test_operator_cannot_delete(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
created = await client.post(PREFIX, json=_payload())
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
assert response.status_code == 403
async def test_admin_can_delete(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("admin")
created = await client.post(PREFIX, json=_payload())
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
assert response.status_code == 204
class TestCrud:
async def test_create_then_read_roundtrip(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
created = await client.post(PREFIX, json=_payload(description="corner of 5th"))
assert created.status_code == 201
body = created.json()
assert body["serial"] == "RSU-0001"
assert body["id"] > 0
assert body["created_at"]
fetched = await client.get(f"{PREFIX}/{body['id']}")
assert fetched.status_code == 200
assert fetched.json() == body
async def test_duplicate_serial_is_409(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
await client.post(PREFIX, json=_payload())
duplicate = await client.post(PREFIX, json=_payload(name="different name"))
assert duplicate.status_code == 409
assert duplicate.headers["content-type"].startswith("application/problem+json")
async def test_patch_only_touches_supplied_fields(
self, client: AsyncClient, as_user: AsUser
) -> None:
as_user("operator")
created = (await client.post(PREFIX, json=_payload(description="original"))).json()
patched = await client.patch(f"{PREFIX}/{created['id']}", json={"status": "maintenance"})
assert patched.status_code == 200
assert patched.json()["status"] == "maintenance"
# Untouched fields survive the PATCH.
assert patched.json()["description"] == "original"
assert patched.json()["name"] == created["name"]
async def test_missing_device_is_404(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.get(f"{PREFIX}/999999")
assert response.status_code == 404
assert response.json()["title"] == "Not Found"
async def test_invalid_body_is_422_with_details(
self, client: AsyncClient, as_user: AsUser
) -> None:
as_user("operator")
response = await client.post(PREFIX, json={"name": "", "serial": ""})
assert response.status_code == 422
assert response.json()["errors"]
async def test_pagination_and_filtering(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("admin")
for index in range(5):
status = "active" if index % 2 == 0 else "inactive"
await client.post(
PREFIX, json=_payload(name=f"rsu-{index}", serial=f"S-{index}", status=status)
)
page = (await client.get(PREFIX, params={"limit": 2, "offset": 0})).json()
assert page["total"] == 5
assert len(page["items"]) == 2
filtered = (await client.get(PREFIX, params={"status": "inactive"})).json()
assert filtered["total"] == 2
assert {item["status"] for item in filtered["items"]} == {"inactive"}
class TestIdentity:
async def test_me_reports_roles(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer", "operator", username="vera")
response = await client.get("/api/v1/me")
assert response.status_code == 200
assert response.json()["username"] == "vera"
assert sorted(response.json()["realm_roles"]) == ["operator", "viewer"]
class TestHealth:
async def test_healthz_needs_no_auth_and_no_dependencies(self, client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_every_response_carries_a_request_id(self, client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.headers["x-request-id"]
async def test_supplied_request_id_is_echoed(self, client: AsyncClient) -> None:
response = await client.get("/healthz", headers={"X-Request-ID": "abc123"})
assert response.headers["x-request-id"] == "abc123"
@pytest.mark.parametrize("path", ["/openapi.json", "/docs"])
async def test_docs_are_reachable(client: AsyncClient, path: str) -> None:
assert (await client.get(path)).status_code == 200