# 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: ```bash make migrate # create the schema make dev # http://localhost:8000 ``` 4. Open , click **Authorize**, and log in as `admin@example.com` / `password`. Every endpoint is now callable from the browser. `make help` lists everything else. | Service | URL | Credentials | | --- | --- | --- | | API | | Bearer token from Keycloak | | Swagger UI | | Authorize button (PKCE) | | Keycloak admin | | `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 | | --- | --- | --- | | `admin@example.com` | `admin`, `operator`, `viewer` | everything, including DELETE | | `operator@example.com` | `operator`, `viewer` | read + create/update | | `viewer@example.com` | `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 . --- ## Getting a token outside the browser The dev realm enables the direct password grant for exactly this: ```bash TOKEN=$(curl -s \ -d 'client_id=v2x-swagger' \ -d 'username=operator@example.com' \ -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: ```python @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 ```bash 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`: ```bash make kc-export # rewrites docker/keycloak/realm-v2x.json ``` Review the diff before committing — exports contain volatile ids and timestamps. --- ## Tests ```bash 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.