Found by running the stack for the first time: - compose build context pointed outside the repo. Relative paths in .devcontainer/compose.override.yaml resolve against the project directory (the repo root), not the file's own directory, so "context: .." escaped the repository and the image could not build at all. - The devcontainers/python base image ships a yarn apt source whose signing key has rotated, failing apt-get update and the whole build. Drop that source list; we don't use yarn. - pytest-asyncio ran fixtures on a session-scoped loop while tests ran on per-function loops, so asyncmy raised "Future attached to a different loop" on every database-backed test. aiosqlite masked this; MySQL does not. Fixture loop scope now matches the test loop scope. - MySQL DATETIME stores no offset, so timestamps serialized bare and left clients guessing. Connections are pinned to UTC, so DeviceRead now attaches that offset explicitly, with a test covering it. Verified end to end against real MySQL 8.4 and Keycloak 26.7: cold start from destroyed volumes, uv sync --frozen, migrations, 41 tests, ruff, mypy --strict, and the live authorization matrix driven by real tokens. Co-Authored-By: Claude Opus 5 <[email protected]>
v2x-server
FastAPI service backed by MySQL, with authentication delegated to Keycloak.
The API is a pure OAuth 2.0 resource server: it validates Bearer access tokens against the realm's published signing keys and never runs a login flow itself. Clients obtain tokens from Keycloak directly.
Quickstart (devcontainer)
Requires Docker and VS Code with the Dev Containers extension. Nothing else — no local Python.
-
Clone the repo and open it in VS Code.
-
Reopen in Container when prompted (or
Cmd-Shift-P→ Dev Containers: Reopen in Container). The first build brings up MySQL and Keycloak, waits for both to report healthy, and installs dependencies fromuv.lock. -
Inside the container:
make migrate # create the schema make dev # http://localhost:8000 -
Open http://localhost:8000/docs, click Authorize, and log in as
[email protected]/password. Every endpoint is now callable from the browser.
make help lists everything else.
| Service | URL | Credentials |
|---|---|---|
| API | http://localhost:8000 | Bearer token from Keycloak |
| Swagger UI | http://localhost:8000/docs | Authorize button (PKCE) |
| Keycloak admin | http://localhost:8080 | admin / admin |
| MySQL | localhost:3306 |
v2x / v2xpassword, database v2x |
Development users
Realm v2x, all with password password. Dev-only; they exist because
docker/keycloak/realm-v2x.json is imported on first boot.
| User | Realm roles | Can |
|---|---|---|
[email protected] |
admin, operator, viewer |
everything, including DELETE |
[email protected] |
operator, viewer |
read + create/update |
[email protected] |
viewer |
read only |
The two URL traps
Almost every "Keycloak + containers" problem is one of these two. Both are already handled here; this section is so you recognise them if you change the configuration.
1. Issuer mismatch
Your browser reaches Keycloak at http://localhost:8080. The API container reaches it at
http://keycloak:8080. A token minted through the browser therefore carries
iss: http://localhost:8080/realms/v2x — and an API that expects the internal hostname rejects
every single token with a misleading "invalid issuer".
The fix is two separate settings rather than one:
| Setting | Value locally | Used for |
|---|---|---|
KEYCLOAK_ISSUER |
http://localhost:8080/realms/v2x |
compared against the token's iss claim |
KEYCLOAK_INTERNAL_URL |
http://keycloak:8080 |
fetching discovery + JWKS |
Keycloak is pinned with KC_HOSTNAME=http://localhost:8080 so all tokens use the browser-facing
issuer, and KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true so the discovery document still hands back a
jwks_uri the container can actually reach. In deployed environments both settings point at the same
public URL and the distinction disappears.
OIDCProvider logs a loud warning at startup if these two disagree.
2. The aud claim is account
By default Keycloak issues access tokens whose audience is account, not your API. Strict audience
validation then rejects everything. The fix is an audience mapper on the client, which
realm-v2x.json already includes for both v2x-api and v2x-swagger.
If you create a new client in the admin console, add the mapper by hand:
Clients → your client → Client scopes → dedicated → Add mapper → By configuration → Audience →
Included Client Audience: v2x-api.
Check what a token actually contains with GET /api/v1/me, or paste it into https://jwt.io.
Getting a token outside the browser
The dev realm enables the direct password grant for exactly this:
TOKEN=$(curl -s \
-d 'client_id=v2x-swagger' \
-d '[email protected]' \
-d 'password=password' \
-d 'grant_type=password' \
http://localhost:8080/realms/v2x/protocol/openid-connect/token | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/me | jq
curl -s -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/devices | jq
Do not enable the password grant in production — it exists here to keep scripted checks simple.
Layout
src/v2x_server/
├── main.py app factory, lifespan, middleware
├── core/ config, logging, error handlers
├── auth/ OIDC discovery, JWKS cache, principal, dependencies
├── db/ declarative base, async engine + session dependency
├── models/ SQLAlchemy models
├── schemas/ pydantic request/response models
├── repositories/ queries, kept out of route handlers
└── api/v1/ routers
devices is a placeholder resource. It exists to prove the whole path — routing, authorization,
validation, ORM, migration, tests — actually works end to end. Replace it with the real domain;
nothing else depends on it.
Authorization
Roles come from the token; require_roles turns them into a dependency:
@router.post("", dependencies=[Depends(require_roles("operator", "admin"))])
async def create_device(...): ...
An unauthenticated request is 401 with a WWW-Authenticate header. An authenticated caller who
lacks the role is 403 — retrying with the same token will never help.
Common tasks
make dev # run with autoreload
make test # pytest
make check # lint + typecheck + test (what CI runs)
make revision m="add sensors" # autogenerate a migration
make migrate # apply migrations
make db # MySQL shell
make reset # destroy all data and containers, start clean
Migrations are never applied automatically at startup: with more than one replica, they would race.
Run alembic upgrade head as an explicit deploy step.
Changing the Keycloak realm
Edit it in the admin console, then persist the change so it survives make reset:
make kc-export # rewrites docker/keycloak/realm-v2x.json
Review the diff before committing — exports contain volatile ids and timestamps.
Tests
make test
Tests run against a real MySQL database (v2x_test, created on first boot), not SQLite: this project
depends on MySQL's types, collation and constraint behaviour, and a SQLite-backed suite would pass
while production broke. Each test runs inside a transaction that is rolled back afterwards.
Endpoint tests override authentication via the as_user fixture. The token validator itself is tested
separately in tests/auth/ against a locally generated RSA keypair, covering expiry, wrong issuer,
wrong audience, unknown key id, alg: none, tampered payloads and missing claims — because a suite
that only ever overrides auth would never notice the validator accepting anything.
Production notes
- Build the runtime image from the root
Dockerfile(multi-stage, non-root, gunicorn + uvicorn workers). - Set
KEYCLOAK_ISSUERandKEYCLOAK_INTERNAL_URLto the same public HTTPS URL. - Run Keycloak in
startmode with a real database, notstart-dev. /healthzis the liveness probe;/readyz(checks MySQL and JWKS) is the readiness probe. Do not point liveness at/readyz— a brief Keycloak outage would otherwise crash-loop the API.