Logging in as a person instead of a keyring
With an identity provider in front of /console, a caller can be an
identity — an email address — rather than a shared API key. The audit
trail then answers "who looked", not "which key was used". Cloudflare Access
and Keycloak are both OIDC issuers underneath, checked the same way; there is
one verifier, not one per provider.
Turning it on — Keycloak (or any other OIDC issuer)
export WORDSWORTH_OIDC_ISSUER=https://iam.westerweel.work/realms/westerweel
export WORDSWORTH_OIDC_AUDIENCE=<the client id registered for wordsworth>
Both are required, checked without a default. Het JWKS-adres komt standaard uit
het eigen discovery-document van de uitgever
(<issuer>/.well-known/openid-configuration, veld jwks_uri), één keer
opgehaald en daarna gecached.
Dat ophalen gebeurt bij het eerste verzoek dat een token controleert, niet bij het opstarten. Een uitgever die hapert hoort geen applicatie neer te halen — en zeker niet de routes die niets met inloggen te maken hebben. Lukt het ophalen niet, dan levert dat "geen caller" op (fail-closed: een verzoek zonder vastgestelde identiteit), niet een dode pod.
Op 2026-09-20 was dat andersom en kostte het de api-pod zijn start:
DiscoveryError: discovery unreachable for issuer '…': HTTPError
[1] [ERROR] Reason: Worker failed to boot.
De sleutels intern ophalen
export WORDSWORTH_OIDC_JWKS_URL=http://keycloak.keycloak.svc.cluster.local:8080/realms/westerweel/protocol/openid-connect/certs
Staat dit adres gezet, dan wordt het discovery-document niet opgehaald en
gaat het verkeer rechtstreeks daarheen. De uitgever blijft de publieke naam:
dat is wat er in iss staat en dus wat gecontroleerd wordt. Adres en identiteit
zijn twee dingen, en ze door elkaar halen betekent dat een interne URL in de
tokencontrole belandt.
Waarom dit uitmaakt: de uitgever is publiek bereikbaar, dus zonder dit adres
gaat de pod via Cloudflare en de eigen tunnel terug het cluster in — twee extra
partijen voor iets wat één netwerkhop verderop staat. Cloudflare weigerde die
aanvraag bovendien met 403, omdat urllib geen User-Agent stuurde. Dezelfde
URL gaf vanaf dezelfde pod 200 zodra er wél een User-Agent bij zat; die stuurt
wordsworth nu altijd mee, zodat ook de publieke route werkt voor wie hem toch
gebruikt.
Wordsworth itself never speaks the OIDC login dance (authorization code,
redirects, session cookies) — a reverse proxy in front of it does that and
forwards the resulting token as Authorization: Bearer <token>. In the
homelab that proxy is oauth2-proxy, configured against the Keycloak realm at
iam.westerweel.work (homelab-repo, cluster-config/infra/keycloak/ for the
realm, oauth2-proxy's own manifest for the proxy itself). That rollout is
out of scope here — this document only covers what wordsworth-api needs once
a token arrives.
Turning it on — Cloudflare Access
export WORDSWORTH_ACCESS_TEAM_DOMAIN=raspy-wood-e123.cloudflareaccess.com
export WORDSWORTH_ACCESS_AUD=<the application's AUD tag>
Both are required. One without the other yields no verifier and no identity — fail-closed, never a warning and an accepted header. If both an OIDC issuer and a Cloudflare team domain are set, the OIDC issuer wins; in practice an installation configures exactly one.
Keep at least one API key. Not for access — the identity handles that — but because dropping them all used to switch off the recipient binding and the issuer scope, and this document actively pointed you at that configuration. Fixed on 2026-09-18: "is there a caller to decide about" now follows the same condition the middleware mounts on. The advice stands anyway: a key is the way back in when the provider is unreachable. Without either, nothing changes: the API key stays the way in, exactly as before. A screen that only opens behind one vendor is not sovereign software, so this is opt-in and stays that way.
The rule this rests on
The header is never a source. The signature is.
Cloudflare Access puts two things on a request: Cf-Access-Authenticated-User-Email,
which is readable, and Cf-Access-Jwt-Assertion, which is signed. oauth2-proxy (for
Keycloak) sends its token as Authorization: Bearer … instead. They arrive together
with the readable header and look equally convincing, and only the signed one cannot
be forged.
Trusting the header would turn every path that does not pass the provider
into a way to claim any identity — and those paths exist here: the tailnet route
(wordsworth-api.tail8f7877.ts.net) reaches the same origin without Access.
Verifying the signature makes that harmless by construction. No valid assertion,
no identity, fall back to the key.
Checked on every assertion: RS256 only (refusing anything else closes the
alg: none family), the signature against the team's published keys, the
audience (an assertion for another application of the same organisation is not
access to this one), the issuer, and the expiry.
The keys are cached for fifteen minutes because the provider rotates them. A failed refresh keeps the keys we have — an assertion we can still verify is not less trustworthy because a fetch timed out, and locking everyone out over a network hiccup is the wrong failure. A key never seen is refused either way.
Keeping the key route
A credential you send beats one that rides along. An identity provider injects its assertion on every request through it, so with the identity first a person behind that provider could never be anything else — and the way back to a key would exist only on routes that bypass the provider, which may be exactly the routes they cannot reach.
So: a valid X-API-Key (or the console cookie) wins; the assertion applies when
no key is presented. Not weaker — both come from the same configured sets, and a
forged header carries no valid key any more than a forged assertion carries a
valid signature.
/console/login stays reachable from behind the provider, and /console/logout
clears the cookie and hands the identity back. That is the switch, in both
directions.
/console/login itself skips the form for someone already identified.
That page is exempt from authentication, so it cannot rely on the middleware
having decided anything — but it can run the same verification itself. A
request carrying a verified assertion is sent straight on to /console
instead of being shown a key form it has no reason to fill in; a missing or
invalid assertion shows the form as before. ?key=1 is the way to ask for the
form anyway — the way back to a key, kept reachable on this one page too.
That form is the one path that is reachable without a key and answers differently for a right and a wrong one. It is rate-limited harder than anything else (five attempts, then one per ten seconds): every request there is a guess. An earlier comment claimed it already was, and it was not — twelve attempts gave twelve 401s and no 429.
Since 2026-09-23 that bucket lives in Postgres (rate_limit_pg.py), shared by
every api replica. With per-process buckets two replicas would have allowed ten
attempts instead of five, and the limit would have doubled again with every
replica added.
The cookie is marked Secure. Its value IS the API key and Path=/ makes it
valid for every endpoint, so it has no business travelling over plain http.
Before you switch: what goes quiet
A grant names who may reveal, and the caller must BE that recipient. Today a
recipient is a key label (console, cli). The moment callers are identities,
nobody is called console and every grant issued to a label authorises nobody.
wordsworth-access-preflight
Reports them and changes nothing. They do not become dangerous, they become inert — and an inert grant that still reads as "active" is a lie in the table. The recipient binding taught this the expensive way: four grants went quiet in August and it was noticed afterwards, by looking.
Re-issuing them to an identity is the administrator's decision. Moving an authorisation from a shared key to a person is a judgement about who should hold it, which is exactly what a machine should not decide on its own.