OIDC expired-session 4xx loop: bound the Check-path refresh to the 200ms ext_authz budget and fail to a 302 #60
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lab/authz-bridge#60
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
After an OIDC Session has expired, a browser holding the (stale) Session cookie is
greeted by repeated 4xx (gateway fail-closed 403, empty body,
server: envoy)on the protected endpoint instead of the 302 re-login. Refreshing does not help;
the only recovery observed was clearing the domain cookies and re-authorizing.
Reported live:
admin.media.animeteamspeak.moe(Appmedia/privatemedia, bridge onthe
minicluster, Kanidm onportable), 2026-09-20 ~16:00–16:14 UTC. The samepattern occurred at 21:55 (Sep 19) and 06:16 (Sep 20) — recurring, not a one-off.
Evidence (correlated bridge + IdP logs)
Bridge (mini,
authz-bridge-system, UTC):Session id_token failed validation—oidc: token is expired (Token Expiry: 2026-09-20 06:31:31 UTC)The Session refresh failed—Post "https://auth.animeteamspeak.moe/oauth2/token": context canceledThe Session refresh failed—context canceledThe Session refresh failed—oauth2: "invalid_grant"Renewed the SessionThe Session refresh failed×5 —oauth2: "invalid_grant"Kanidm (portable,
kanidm/kanidm-0; notekubectl --timestampsprints EDT, −04:00):Two facts fall out of the correlation:
round-trip (mini → portable gateway → kanidm nginx sidecar → kanidm; Kanidm alone
181–201ms) exceeds the gateway's hardcoded 200ms ext_authz gRPC timeout; Envoy
cancels the gRPC call (
context canceled) and returns its own 403(
failure_mode_allow=false). The bridge's 302 never reaches the browser.(Same root cause as #53: the GRPC branch of Cilium's GEP-1494 translation never
sets
Timeout— cilium/cilium#48354, no API knob.)while the bridge's context is canceled at ~200ms loses the OK + re-issued
Set-Cookie; the browser keeps presenting the now-dead refresh token, so everysubsequent request is a refresh → 400 (
invalid_grant) or a 403 when theround-trip is slow → the stuck 4xx loop. (Kanidm goes further than rotation: on
refresh-token reuse it cancels the whole session — see the design doc in
References. That is why clearing the cookie, not a retry, is the only recovery.)
Clearing the cookie removes the dead token from the picture: no cookie →
immediate 302 (no IdP I/O) → clean login.
Root cause (bridge-side)
internal/serve/oidc.go— the lazy refresh (ADR 0008) is the only OIDC Checkpath that does IdP I/O, and it does so unbounded on the gateway's request context:
Check(line ~211):s.renew(ctx, app, refreshToken)— no deadline against the200ms budget.
renew(lines ~329–363): runs the refresh grant on the gateway's context, so aslow grant is canceled mid-flight and the bridge's response never lands in budget.
Because the grant is unbounded, the Check response is not guaranteed to be delivered
before the gateway's 200ms timer fires; when it isn't, Envoy returns its own 403 and
the browser is stuck (fact 2 above).
Fix
Bound the refresh grant to the gateway's budget and fail to a 302.
Run the refresh grant with its own deadline of 190ms — under the 200ms ext_authz
timeout. Colocation keeps the mini→portable network to ~5ms, leaving ~185ms for
Kanidm's server-side processing (measured 93–181ms), so a healthy refresh still
completes in budget. On deadline, treat it as a failed refresh →
relogin(302).The Check response is then always delivered in-budget: the client gets a proper 302
(or a renewed OK) instead of a gateway 403, and the stuck 4xx loop becomes a
re-login. The rotation trap is avoided structurally: the browser always receives
either the renewed OK (with the new token) or a 302 (re-login) — it never retries
with the stale, now-dead token, so Kanidm's reuse detection is never triggered.
Consider making the deadline configurable per-App (e.g. on
OidcSession), defaultingto 190ms.
Out of scope
Caching the renewal (per-replica token cache keyed by the old refresh token).
Considered and dropped. It would recover the session transparently on the next
request, but:
rotated and dead after one use. A cache mapping the old (dead) refresh token →
the new (live) token would serve a live token to a stolen stale refresh token
on a cache hit, bypassing Kanidm's reuse detection (which cancels the whole
session). Without the cache, that same stolen dead token is rejected by Kanidm.
This is a security weakening, not just added complexity. (Contrast the Exchange
flow's
tokenCache, which is safe because its key — the service-account APItoken — is a live, non-rotated credential, not a dead one.)
rather than retrying the stale cookie, so the cache rarely hits;
different replica misses the cache and re-presents the dead token, triggering
Kanidm's session-wide reuse cancellation;
for this fix.
Revisit only if the occasional re-login on a slow refresh proves to be a UX problem.
Connection warming / re-warming — tried before; caused more issues than it
solved. Not part of this fix.
The gateway-side timeout — #53 (cilium/cilium#48354) remains the upstream fix;
this issue makes the bridge correct under the 200ms constraint.
Verification
cookie (no 403); a refresh that sleeps past the deadline → 302 (token-endpoint
double that sleeps past the deadline).
admin.media.animeteamspeak.moe(X-Request-Idrecipe in
docs/agents/deploy.md); expect a 302 (or a renewed OK) instead of a4xx loop; bridge logs show the new deadline line.
References
https://kanidm.github.io/kanidm/stable/developers/designs/oauth2_refresh_tokens.html
OIDC expired-session 4xx loop: bound the Check-path refresh to the 200ms ext_authz budget and cache the renewalto OIDC expired-session 4xx loop: bound the Check-path refresh to the 200ms ext_authz budget and fail to a 302Implemented and deployed
Fix (authz-bridge
4f0316d): the refresh grant on the Check path now runs under its own deadline —OidcSession.RefreshDeadline, default 190ms, under the 200ms ext_authz budget — and a deadline is treated as a failed refresh →relogin(302). The value is deliberately not clamped so it can track the gateway's timeout if it changes (hazard documented in the API field comment + ADR 0008 addendum).Image:
harbor.animeteamspeak.moe/library/authz-bridge:v0.1.0-4-g01eb540@sha256:509dd435…(clean tag, built from the committed tree).Deployed to both clusters:
portable— skaffold, 3/3 replicas rolled out.mini— via flux-clustersc452f0f1(image pin + therefreshDeadlineCRD field), rollout confirmed;admin.media.animeteamspeak.moebaseline (no session → 302) verified against the new bridge.Verification
Unit tests (
internal/serve/oidc_refresh_test.go, token-endpoint double with a delay param):Live (portable,
httpbin-auth): forged a stale Session cookie (random-keyid_tokenwith the App's real issuer/audience but expiredexp, dummy refresh token, encoded with the bridge's real cookie key) and sent it through the gateway:Session id_token failed validation(signature) →The Session refresh failed(Kanidm rejected the dummy token) → 302;Not exercised live (by construction, not by omission):
id_tokenapproaching expiry. The next natural occurrence will now log eitherRenewed the Session(OK + re-issued cookie) or the newThe Session refresh exceeded its deadlineline, instead ofcontext canceled+ a gateway 403.The rotation trap is avoided structurally as designed: the browser always receives either the renewed OK (new token) or a 302 (re-login) — it never retries with the stale, now-dead refresh token, so Kanidm's reuse detection is never triggered.
Fixed in
4f0316d(shipped in v0.1.2). The lazy refresh (ADR 0008) now runs the refresh_token grant under its own deadline - OidcSession.RefreshDeadline, default 190ms, under the gateway's 200ms ext_authz budget (refreshDeadlineOf, internal/serve/oidc.go). A deadline is treated as a failed refresh, so the Check path returns a 302 relogin delivered in-budget. The browser always receives either the renewed OK (with the re-issued cookie) or a 302, so it never retries with the now-dead (rotated) refresh token and Kanidm's at-most-once reuse detection is never triggered. Unit tests cover within-deadline -> OK + re-issued cookie and past-deadline -> 302. Out-of-scope items (caching the renewal, connection warming, the gateway-side timeout) remain as documented; the gateway-side timeout is tracked by #53 (cilium/cilium#48354).