gnickensandClaude Opus 5 c7838acbbf
CI / check (push) Canceled after 0s
Fix devcontainer build, event-loop scope, and UTC timestamps
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]>
2026-09-10 13:02:13 -04:00

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.
S
Description
No description provided
Readme
172 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 93.8%
Dockerfile 2.9%
Makefile 2.5%
Mako 0.8%