Files
v2x-server/README.md
T
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

193 lines
7.3 KiB
Markdown

# 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 <http://localhost:8000/docs>, 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 | <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 |
| --- | --- | --- |
| `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 <https://jwt.io>.
---
## 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 '[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:
```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.