45. scrap verifies the login proxy’s Keycloak token#
Date: 2026-11-04
Status#
Accepted. Takes up the SCRAP_ALLOWED_USER check that
23. Bearer token on /mcp and an identity-header setting left for later.
Amended 2026-11-06: the owner is checked by Keycloak username (the DLS fedid), not by email, since DLS Keycloak does not verify emails. See Amendment: the owner is the fedid at the end.
Context#
At DLS each user’s scrap sits behind one central oauth2-proxy, through ingress-nginx’s external
auth (authProxy in the chart, Run the DLS login proxy). That guards the
Ingress only. argus users cannot make NetworkPolicies, so any pod in the cluster can call a
user’s scrap Service directly, and send its own X-Auth-Request-Email: scrap had no login of its
own and trusted that header as sent.
Decision#
scrap checks a Keycloak access token on every request, when
SCRAP_OIDC_ISSUERis set. Unset, nothing changes. The check issrc/scrap/oidc.py: ASGI middleware outside all the others, so nothing (not the Host check, not a body read) runs before it.Where the token comes from. oauth2-proxy runs with
pass_access_token, so its/oauth2/authanswer carriesX-Auth-Request-Access-Token; the chart adds that header to ingress-nginx’sauth-response-headers, which sets it on the request to scrap over any copy the client sent. A pod calling the Service has no such token for the owner, and cannot make one.What is checked, with PyJWT: the signature, against the keys at the
jwks_uriof the issuer’s discovery document (whoseissuermust match); only RS256 and ES256, and only the algorithm the key is for, nevernoneor a shared secret;typBearer(an access token, not an ID token; compared ignoring case);issexactlySCRAP_OIDC_ISSUER; issued to our client (azpisSCRAP_OIDC_CLIENT_ID, or it is inaud, since Keycloak’s access tokens haveaud: accountunless a mapper adds the client);exp,nbfandiatwith 30 s of leeway; andpreferred_username(the Keycloak username, at DLS the fedid) inSCRAP_ALLOWED_USERS(lower-cased when read). The claim must be lower-case ASCII and match exactly, never folded; a token without it, or with any other spelling, gets401. This allow-list is the only owner check: the token alone says only that someone at DLS logged in, and the Ingress’sauth-urlcarries noallowed_emails(DLS’s ingress-nginx refuses an@or%in it), so any DLS login passes the proxy, and another user gets scrap’s401.Exempt:
/healthz(the kubelet’s probes carry no token) and/mcp,/mcp/..., which keep their own bearer token (ADR 23) on the Ingress without the login. So the issuer without an/mcptoken (SCRAP_MCP_TOKENorSCRAP_MCP_TOKEN_FILE) stops scrap starting:/mcpwould be open to any pod.Fail closed. No token, or one that fails a check:
401(JSON under/api, plain text elsewhere), no redirect (the Ingress does the login). Keys that cannot be fetched:503. Any other error:401. An issuer without a client ID or an allow-list, or one that is not https (loopback aside), stops scrap starting.Keys. Discovery runs on the first request, not at start, so a Keycloak outage is a
503rather than a crash loop. Itsjwks_urimust be https (http only on loopback, as for the issuer). scrap refreshes the key set after an hour, with its own back-off rather thanPyJWKClient’s (whose cooldown starts only after a fetch that worked, so during an outage every request would fetch): one fetch at a time, never holding up other requests (one the cached keys cannot answer meanwhile gets503); after a failed fetch none for 10 s (503); and an unknown key ID refetches the set at most once per 30 s (401meanwhile). So, whatever tokens a pod sends, scrap makes at most one fetch per 10 s while Keycloak is down and one per 30 s for unknown key IDs, and a rotated key is picked up within 30 s. A key set that is not JSON, not an object, or has no usable signing key (onlyuse: sigor unmarked keys count) is a failed fetch (503).A stale key set. While the hourly refresh is in flight the old set still answers, so the refresh costs nobody a
503. If that refresh fails, the expired set is dropped and the web app answers503until a fetch works. Keeping it through an outage would also verify (signatures still check, andexpbounds each token), but a key Keycloak has removed may have been removed because it leaked, and scrap could not tell; an outage long enough to matter also stops logins and the proxy’s refreshes, so little is lost by failing closed. A failed refetch for an unknown key ID before the hour is up keeps the set.Fetching. The standard library: http(s) only, no redirects, 5 s per socket operation, and the body read a piece at a time, given up past 10 s from the start or past 1 MiB (
503). The request and headers are bounded per operation only, but a slow fetch holds no lock and blocks no other request.Freshness. Keycloak’s access tokens last 5 minutes by default, so the proxy refreshes its session every minute (
cookie_refresh) with its refresh token. Its scopes areopenid email profileonly: nooffline_access, so a login lasts as long as the user’s Keycloak session, and then the refresh fails and they log in again.The author of a change is the token’s username (the fedid), the identity the check admitted, and no identity header is read (
SCRAP_IDENTITY_HEADERis ignored while the check is on)./mcpchanges stay Claude’s.Helm.
authProxy.oidc.issuerand.clientIdare required withauthProxy.enabled; the Deployment getsSCRAP_OIDC_ISSUER,SCRAP_OIDC_CLIENT_IDandSCRAP_ALLOWED_USERS(the lower-casedallowedUsers). The CronJobs serve no HTTP and do not.
Consequences#
A pod that reaches the Service gets
401on everything but/healthzand/mcp, and/mcpneeds the bearer token./healthzstill answers anyone, as before.Keycloak’s access tokens must carry
preferred_username(its defaultprofilescope maps it into the access token) and name the client inazp. Whether the email is verified does not matter.A token taken from the owner’s browser is good for its few remaining minutes, as the login cookie is for longer: the check stops forgery, not theft.
kubectl port-forwardto the Service reaches/healthzand/mcponly; the web app needs the Ingress.The web app is unusable while Keycloak’s keys cannot be had (after the cached hour), which is also when nobody can log in.
Amendment: the owner is the fedid#
The first version checked email_verified true and email in SCRAP_ALLOWED_EMAILS. DLS
Keycloak does not verify emails: real staff accounts’ tokens carry email_verified: false.
The first login on argus failed in oauth2-proxy, which refuses an unverified email
(email in id_token (giles.knap@diamond.ac.uk) isn't verified, a 500), and loosening the
proxy alone would only have moved the failure to scrap’s 401.
So the owner is the Keycloak username, preferred_username. At DLS that is the fedid, the
same identity that names the user’s argus namespace (namespace abc12345 is fedid
abc12345), so the installer takes it from the namespace. The profile scope carries it
into the access token. So the trust anchor is that DLS users cannot change their own username:
the realm has “Edit username” off (and no “Email as username” with editable emails), and
preferred_username comes from Keycloak’s built-in username mapper, not an editable
attribute. The token’s claim must be lower-case ASCII and match exactly; scrap does not fold
it, so no look-alike spelling passes. The proxy sets insecure_oidc_allow_unverified_email:
it admits any DLS login, as before, and each scrap admits only its owner. SCRAP_ALLOWED_EMAILS and
authProxy.allowedEmails are gone, not kept as aliases: they shipped in 1.6.0 a day earlier
and had no users but the owner, whose login never worked.
The proxy keeps email_domains = ["diamond.ac.uk"]: an unverified email is still one DLS
Keycloak holds for the account, and the domain check costs nothing. The author of a change
is now the username too: it is the identity the check admitted, while the email is not
checked at all.