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]>
193 lines
7.3 KiB
Markdown
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.
|