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]>
This commit is contained in:
commit
0526d34e42
52 files changed
+4433
No files matched your search
@@ -0,0 +1,192 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user