Reference API: Kanidm OIDC for humans, RFC 8693 token exchange + mTLS PKI for AI agents
  • Python 79.8%
  • CUE 11.1%
  • Just 9.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Thomas Miller 0fa8e4ba8e chore: drop __pycache__, add .gitignore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTenpGsjD8NN1NeVyzAcg8
2026-08-13 20:30:13 -04:00
quality hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00
tests hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00
.gitignore chore: drop __pycache__, add .gitignore 2026-08-13 20:30:13 -04:00
.woodpecker.yml hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00
app.py hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00
justfile hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00
README.md hello-api: reference API with Kanidm OIDC, RFC 8693 token exchange, and mTLS PKI for agents 2026-08-13 20:29:25 -04:00

hello-api

Reference API for the reti platform, live at https://api.reti-infra.com. One JSON endpoint, three authentication paths, all against the platform's own identity stack (Kanidm on kanidm-01). This repo is the template for the 510 production APIs that follow.

Three ways in

Caller Path Mechanism
Human browser → /login Kanidm OIDC authorization-code + PKCE (passkey). Group hello_api_users required.
AI agent (token) Authorization: Bearer … Kanidm RFC 8693 token exchange: service-account API token → short-lived (900 s) OAuth2 access token, audience=hello-api. Validated server-side by RFC 7662 introspection. Group hello_api_agents.
AI agent (PKI) TLS client certificate mTLS at Caddy against the reti agent CA (/etc/reti/agent-ca/ca.crt on apps-01). Identity = certificate CN. Certificate possession is the authorisation.

Both agent paths coexist with the human path on the same vhost: Caddy's client_auth mode verify_if_given lets browsers connect certificate-less while any presented certificate must chain to the agent CA.

Agent quickstart (token exchange)

TOK=<service-account api token>           # kanidm service-account api-token generate svc-hello-agent …
AT=$(curl -s -X POST https://idm.reti-infra.com/oauth2/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d client_id=hello-api -d audience=hello-api \
  --data-urlencode subject_token="$TOK" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d "scope=openid groups_name" | jq -r .access_token)
curl -H "Authorization: Bearer $AT" https://api.reti-infra.com/api/hello

Note: for Kanidm basic OAuth2 clients the API token itself is the credential in this flow — do not send the client secret.

Agent quickstart (PKI / mTLS)

curl --cert svc-hello-agent.crt --key svc-hello-agent.key \
     https://api.reti-infra.com/api/hello

Certificates are issued from the agent CA on apps-01 (/etc/reti/agent-ca/, key root-only). Issue a new agent cert with openssl: CSR with CN=<agent name>, sign with the CA, extendedKeyUsage=clientAuth.

Where the new APIs should live (platform decision)

  • Host: apps-01, loopback listener (ports 50005999, one per service), behind Caddy. No new hosts needed at this scale; the CVM has headroom.
  • Routing: one subdomain vhost per API (<name>.reti-infra.com) — matches every existing service on the box, gives per-API TLS/mTLS policy, and avoids path-prefix rewriting. Reserve api.reti-infra.com for this reference/gateway; a future consolidated gateway can grow here.
  • Process model: one systemd unit per API, User=www-data (or a dedicated user), secrets via LoadCredential= from /etc/reti/secrets/ (root-only), non-secret config in /opt/<name>/<name>.env (644). Containers (podman quadlet) are the alternative for non-Python runtimes — both patterns are already in production on the host.
  • Source of truth: one Forgejo repo per API (this repo is the template), CI in Woodpecker, images (if any) in zot (registry.reti-infra.com).

Dev workflow (Woodpecker + CUE)

  1. Branch, edit, push to Forgejo → webhook fires Woodpecker (ci.reti-infra.com, agent on apps-01, podman backend).
  2. .woodpecker.yml runs two mandatory gates:
    • quality-gatecue vet ./quality/: quality/release.cue (the service's concrete manifest: port, vhost, groups, credential names, UTC declaration) must satisfy the platform schema quality/service.cue. Policy lives in the schema; violating manifests cannot merge.
    • testpython3 -m py_compile + stdlib unit tests.
  3. Merge to main on green.
  4. Deploy (operator or agent, from the cockpit): just deploy — fetches app.py from Forgejo raw, installs to /opt/hello-api/, restarts the unit, probes /healthz.

One-time per new repo: an operator activates the repo in the Woodpecker UI (https://ci.reti-infra.com, Kanidm-federated via Forgejo login) so the webhook is installed.

The CUE schema is deliberately platform-wide: copy quality/service.cue into each new API repo (or later, publish it as a CUE module in Forgejo) and write a ten-line release.cue. The gate then enforces the hosting decisions above mechanically.

Identity: what Kanidm can and cannot do (researched 2026-08-13)

  • PKI certificates for users/agents inside Kanidm: not supported. Kanidm has no x509 login flow. That is why PKI lives at the TLS edge (Caddy client_auth) with our own CA. This works today and is what this service demonstrates.
  • Machine credentials: Kanidm has no OAuth2 client_credentials grant (issue #2217); the supported machine flow is service-account API tokens + RFC 8693 token exchange, which this service uses.
  • Google login: not possible through Kanidm. Kanidm has no upstream IdP federation — it cannot delegate authentication to Google, GitHub, or any external OIDC/SAML provider (issue #4015, discussion #3163). Federation is on the roadmap, not prioritised.
  • GitHub login: same answer — not through Kanidm.
  • Can PKI certs + Google (or GitHub) login coexist on one API? Yes — they compose at different layers, so Kanidm's limitation only affects where the human login comes from:
    • mTLS stays at Caddy exactly as now (agents, certificate-holders);
    • human OIDC can point at any OIDC provider. To offer Google/GitHub/Kanidm side by side, put a small federating broker in front: Dex (single binary, config-file driven, fits the host's no-container-required doctrine) with three connectors — oidc (Google), github, and oidc (Kanidm). Apps then speak OIDC to Dex only. Alternatives with the same shape: Keycloak, authentik, Zitadel (all heavier). A second oauth2-proxy instance per provider is the no-new-software fallback but cannot mix providers on one cookie.
    • Google side: create an OAuth client in Google Cloud Console (redirect https://<broker>/callback); GitHub side: an OAuth App in GitHub Developer settings. Their client ids/secrets go in the broker's config, delivered via LoadCredential= like every other secret here.

Where this configuration is stored (file inventory)

Concern Location
Reverse proxy, vhosts, mTLS client_auth /etc/caddy/Caddyfile (apps-01); agent CA trust copy /etc/caddy/agent-ca.crt
Agent PKI CA + issued certs /etc/reti/agent-ca/ (apps-01, root-only key)
Kanidm server daemon /etc/kanidmd/server.toml (kanidm-01); client defaults /etc/kanidm/config
Kanidm OAuth2 clients, groups, accounts, scope maps not files — objects in the Kanidm database (/var/lib/kanidmd/kanidm.db), managed via kanidm system oauth2 …, kanidm group …, kanidm service-account …
This app's non-secret config /opt/hello-api/hello-api.env (apps-01)
This app's secrets /etc/reti/secrets/helloapi_* → systemd LoadCredential= in /etc/systemd/system/hello-api.service
oauth2-proxy pattern (existing ai-gateway) /etc/oauth2-proxy/oauth2-proxy.cfg (apps-01)
A future Dex broker /etc/dex/config.yaml (connectors incl. Google/GitHub client ids) + secrets via LoadCredential
Woodpecker CI /etc/woodpecker/server.env, /etc/woodpecker/agent.env (apps-01)
DNS (api.reti-infra.com → apps-01) Cloudflare zone reti-infra.com

Kanidm objects backing this service

group    hello_api_users   (members: thomas.miller)
group    hello_api_agents  (members: svc-hello-agent, agent-claude)
svc acct svc-hello-agent   (API token "hello-api-demo")
oauth2   hello-api         origin https://api.reti-infra.com/
                           redirect https://api.reti-infra.com/callback
                           scope maps: users → openid profile email groups_name
                                       agents → openid groups_name

Repo layout

app.py               the service (Python stdlib only, ~250 lines)
quality/service.cue  platform quality-gate schema (CUE)
quality/release.cue  this service's concrete manifest
tests/test_app.py    stdlib unit tests
.woodpecker.yml      CI: cue vet + tests
justfile             deploy/status/demo recipes (run from the cockpit)