Files
gnickensandClaude Opus 5 0526d34e42 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]>
2026-09-10 12:46:52 -04:00

7.3 KiB

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.

  1. Clone the repo and open it in VS Code.

  2. 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 from uv.lock.

  3. Inside the container:

    make migrate    # create the schema
    make dev        # http://localhost:8000
    
  4. 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_ISSUER and KEYCLOAK_INTERNAL_URL to the same public HTTPS URL.
  • Run Keycloak in start mode with a real database, not start-dev.
  • /healthz is 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.