fix(apisix): restart the login flow instead of 500ing on an expired auth session - #5417
fix(apisix): restart the login flow instead of 500ing on an expired auth session#5417blarghmatey wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds APISIX recovery for expired OIDC authentication sessions, restarting login instead of returning HTTP 500.
Changes:
- Adds guarded Lua callback recovery.
- Enables recovery for MIT Learn, MITx Online, and JupyterHub.
- Adds configuration-rendering and source-level tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/ol_infrastructure/components/services/apisix.py |
Defines the recovery plugin. |
src/ol_infrastructure/applications/mit_learn/__main__.py |
Enables recovery for MIT Learn. |
src/ol_infrastructure/applications/mitxonline/__main__.py |
Enables recovery for MITx Online. |
src/ol_infrastructure/applications/jupyterhub/deployment.py |
Enables recovery for JupyterHub. |
tests/ol_infrastructure/components/services/test_apisix.py |
Tests plugin generation and rendering. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ping it Addresses both Copilot findings on #5417. The string assertions only proved a fragment was present. An inverted branch could satisfy every one of them and still break production authentication -- so add a lupa-backed harness that loads the generated function into a Lua runtime with a stubbed ngx/apisix.core and asserts on the redirect, status, and Set-Cookie it actually produces. Verified to have teeth by mutation: removing the guard-cookie early return fails 1 test, and reintroducing the %%-escaping bug that broke every Lua pattern during development fails 7. lupa runs in-process, so this needs no interpreter on the CI runner and no workflow change. Also fix the default handling: `recoverable_errors or [...]` meant an explicit empty list fell through to the default and silently enabled recovery, when it should disable it -- that being how a caller makes the plugin a no-op without detaching it from every route on a shared plugin config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…st real APISIX Addresses rhysyngsun's three review threads on #5417. The Lua now lives in components/services/files/oidc_error_callback_recovery.lua and is shipped verbatim. Tunables travel as an `oidc_error_recovery` block on the plugin config and are read off `conf` -- verified against the pinned 3.17.0 image, not master: serverless/init.lua invokes each function as `func(conf, ctx)` and its schema sets no additionalProperties, so extra keys validate. The whole path is opaque to unknown keys at every hop: both CRDs mark config x-kubernetes-preserve-unknown-fields, the controller holds it as apiextensionsv1.JSON, and ADC as map[string]any. The guard-cookie check is a single ctx.var["cookie_<name>"] read instead of scanning the cookie header, so nginx does the parsing and the name match is exact. (The DoS framing does not hold -- the URI check returns before any cookie work, so the scan only ever ran on callbacks with a recoverable error -- but the lookup is better code regardless.) Two new test layers, because the stubbed unit tests genuinely could not see whether APISIX accepts the config or whether OpenResty behaves as assumed: tests/apisix_integration 11 tests against a real APISIX 3.17.0 container in standalone mode, fed the route config built by the actual plugin helper. Marked `integration`, so the existing `-m 'not integration'` default keeps them out of the fast suite. tests/apisix_testnginx APISIX's own test-nginx harness (19 subtests) on the same OpenResty build the gateway runs, with Test::Nginx and etcd in the image. Builds from the repo root so it copies the Lua from src/ -- a second copy would be free to drift and the suite would then pass against Lua we do not run. Both are wired as separate CI jobs. All three layers mutation-tested: dropping the loop guard fails 1 integration test, simulating the config block being stripped fails 6, and removing the repeated-error table handling fails 2 test-nginx subtests. This also settles the Seer prediction from the previous round empirically -- Set-Cookie demonstrably survives ngx.redirect on the real runtime, which had only been argued from lua-nginx-module's source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/ol_infrastructure/components/services/files/oidc_error_callback_recovery.lua:24
- This suffix check also matches non-callback paths such as
/login/foo.apisix/redirect, even though APISIX 3.17 constructs and recognizes callbacks with the exact/.apisix/redirectsuffix. Because this plugin is attached host-wide, such application requests witherror=temporarily_unavailableare now unexpectedly redirected. Include the slash in the match (and add a near-match regression case) so only actual OIDC callbacks are intercepted.
if not uri or not uri:match("%.apisix/redirect$") then
tests/apisix_integration/conftest.py:35
APISIX_IT_IMAGEis documented above as an override, but this constant never reads it, so setting the variable silently continues testing 3.17.0. That can produce false confidence when using the suite to validate an image upgrade; either honor the environment variable here or remove the override claim.
APISIX_IMAGE = "apache/apisix:3.17.0-debian"
src/ol_infrastructure/applications/mit_learn/main.py:1350
- The PR description says this attachment should make
APISIXOIDCCallbackFailureRateChronicgo quiet, but that rule is>5%and explicitly documents silence only after both known causes are fixed (log_rules/apisix_oidc.py:72-76, 183-186). This PR leaves stale replay and exchange failures open; for api.learn alone the stated 191 + 55 remaining failures against the measured callback volume still exceed 5%, even after adding the recovery callbacks. Alert silence therefore cannot be the deployment confirmation claimed in the PR; update that validation expectation (for example, verify the expected rate reduction or query this error specifically).
oidc_error_callback_recovery_plugin(),
|
Picked up the three suppressed findings from the latest Copilot review — they had no inline threads, so replying here. 1. 2. 3. The alert-silence claim was wrong — thank you, this one mattered. I checked the arithmetic and Copilot is right.
The replay 500s alone hold both hosts above the threshold — removing 49% of the absolute count is not the same as clearing a 5% ratio. I had stated the opposite confidently in the PR description, the tracking task, and a team memory; all three are corrected. The deployment check is now the No rule change included: the rule behaves as designed, it was the claim about it that was wrong. Adding a |
…ping it Addresses both Copilot findings on #5417. The string assertions only proved a fragment was present. An inverted branch could satisfy every one of them and still break production authentication -- so add a lupa-backed harness that loads the generated function into a Lua runtime with a stubbed ngx/apisix.core and asserts on the redirect, status, and Set-Cookie it actually produces. Verified to have teeth by mutation: removing the guard-cookie early return fails 1 test, and reintroducing the %%-escaping bug that broke every Lua pattern during development fails 7. lupa runs in-process, so this needs no interpreter on the CI runner and no workflow change. Also fix the default handling: `recoverable_errors or [...]` meant an explicit empty list fell through to the default and silently enabled recovery, when it should disable it -- that being how a caller makes the plugin a no-op without detaching it from every route on a shared plugin config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…st real APISIX Addresses rhysyngsun's three review threads on #5417. The Lua now lives in components/services/files/oidc_error_callback_recovery.lua and is shipped verbatim. Tunables travel as an `oidc_error_recovery` block on the plugin config and are read off `conf` -- verified against the pinned 3.17.0 image, not master: serverless/init.lua invokes each function as `func(conf, ctx)` and its schema sets no additionalProperties, so extra keys validate. The whole path is opaque to unknown keys at every hop: both CRDs mark config x-kubernetes-preserve-unknown-fields, the controller holds it as apiextensionsv1.JSON, and ADC as map[string]any. The guard-cookie check is a single ctx.var["cookie_<name>"] read instead of scanning the cookie header, so nginx does the parsing and the name match is exact. (The DoS framing does not hold -- the URI check returns before any cookie work, so the scan only ever ran on callbacks with a recoverable error -- but the lookup is better code regardless.) Two new test layers, because the stubbed unit tests genuinely could not see whether APISIX accepts the config or whether OpenResty behaves as assumed: tests/apisix_integration 11 tests against a real APISIX 3.17.0 container in standalone mode, fed the route config built by the actual plugin helper. Marked `integration`, so the existing `-m 'not integration'` default keeps them out of the fast suite. tests/apisix_testnginx APISIX's own test-nginx harness (19 subtests) on the same OpenResty build the gateway runs, with Test::Nginx and etcd in the image. Builds from the repo root so it copies the Lua from src/ -- a second copy would be free to drift and the suite would then pass against Lua we do not run. Both are wired as separate CI jobs. All three layers mutation-tested: dropping the loop guard fails 1 integration test, simulating the config block being stripped fails 6, and removing the repeated-error table handling fails 2 test-nginx subtests. This also settles the Seer prediction from the previous round empirically -- Set-Cookie demonstrably survives ngx.redirect on the real runtime, which had only been argued from lua-nginx-module's source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
09b730a to
27d636f
Compare
Verified on CIDeployed this branch's shared-plugin config to the CI stack and ran the probes. The hop no test layer reaches — CRD → ingress-controller → ADC, against real Keycloak — works: the Deploy was scoped with
Probe 1 matches the expected Not covered: a full credentialed login. Probe 5 gets as far as Keycloak rendering the form; completing the code exchange needs credentials I don't have. Worth one manual browser login through https://ci.learn.mit.edu before production. Merge-order interaction with #4759#4759 adds a second shared plugin config, In practice this probably does not hit the real callback: the callback is a cross-origin GET top-level navigation from Keycloak, and browsers don't attach Either way it's a latent trap — whichever of these two PRs merges second should attach Unrelated: CI stack has pending operationsThe CI stack state carries 4 pending |
…uth session Measuring the OIDC callback 500s against production Loki and Keycloak's own event stream splits them into three causes, only one of which blocks anybody: temporarily_unavailable 614/day real -- this commit stale-code replay ~465/day none -- the first callback already succeeded genuine exchange failure ~171/day real but small (2.0% api.learn, <=6.5% mitxonline) The first is an authorization request whose Keycloak authentication session expired: the user left the login tab open or followed a stale bookmark, and Keycloak redirects back with error=temporarily_unavailable and no code, marking its own event restart_after_timeout="true". lua-resty-openidc treats any error parameter as fatal, so APISIX answers with a 21KB HTTP 500 and nothing retries. 530 distinct client addresses a day hit this; 181 of them never reached a successful callback in the same 24 hours. Keycloak corroborates that the other two causes are not what they looked like: 4,762 successful code_to_token exchanges against only 7 invalid_code errors, so the replayed callbacks die at APISIX's state check and never reach the token endpoint at all. That is why the 27.4%/14.4% callback failure rates in the audit are not login failure rates. The recovery plugin runs in the rewrite phase, where serverless-pre-function's priority (10000) outranks openid-connect's (2599), and redirects back into the authorization flow. The target is derived from the callback URI rather than configured -- the callback always sits at <login prefix>/.apisix/redirect and the route serving it is by construction the unauth_action="auth" one -- so one attachment on a host's shared plugin config covers every route group on it, including mit-learn's /login and /learn/login prefixes. Only transient errors are recovered; access_denied (the user pressed Cancel) must keep failing rather than spin the browser between gateway and Keycloak. A short-lived guard cookie bounds recovery to one attempt per browser per minute so a persistently broken IdP surfaces as an error instead of a redirect loop. No change needed to the alert rules added in #5413: this removes ~49% of the callback 500s at api.learn and mitxonline, so the chronic rule going quiet is the signal that it worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…ping it Addresses both Copilot findings on #5417. The string assertions only proved a fragment was present. An inverted branch could satisfy every one of them and still break production authentication -- so add a lupa-backed harness that loads the generated function into a Lua runtime with a stubbed ngx/apisix.core and asserts on the redirect, status, and Set-Cookie it actually produces. Verified to have teeth by mutation: removing the guard-cookie early return fails 1 test, and reintroducing the %%-escaping bug that broke every Lua pattern during development fails 7. lupa runs in-process, so this needs no interpreter on the CI runner and no workflow change. Also fix the default handling: `recoverable_errors or [...]` meant an explicit empty list fell through to the default and silently enabled recovery, when it should disable it -- that being how a caller makes the plugin a no-op without detaching it from every route on a shared plugin config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…st real APISIX Addresses rhysyngsun's three review threads on #5417. The Lua now lives in components/services/files/oidc_error_callback_recovery.lua and is shipped verbatim. Tunables travel as an `oidc_error_recovery` block on the plugin config and are read off `conf` -- verified against the pinned 3.17.0 image, not master: serverless/init.lua invokes each function as `func(conf, ctx)` and its schema sets no additionalProperties, so extra keys validate. The whole path is opaque to unknown keys at every hop: both CRDs mark config x-kubernetes-preserve-unknown-fields, the controller holds it as apiextensionsv1.JSON, and ADC as map[string]any. The guard-cookie check is a single ctx.var["cookie_<name>"] read instead of scanning the cookie header, so nginx does the parsing and the name match is exact. (The DoS framing does not hold -- the URI check returns before any cookie work, so the scan only ever ran on callbacks with a recoverable error -- but the lookup is better code regardless.) Two new test layers, because the stubbed unit tests genuinely could not see whether APISIX accepts the config or whether OpenResty behaves as assumed: tests/apisix_integration 11 tests against a real APISIX 3.17.0 container in standalone mode, fed the route config built by the actual plugin helper. Marked `integration`, so the existing `-m 'not integration'` default keeps them out of the fast suite. tests/apisix_testnginx APISIX's own test-nginx harness (19 subtests) on the same OpenResty build the gateway runs, with Test::Nginx and etcd in the image. Builds from the repo root so it copies the Lua from src/ -- a second copy would be free to drift and the suite would then pass against Lua we do not run. Both are wired as separate CI jobs. All three layers mutation-tested: dropping the loop guard fails 1 integration test, simulating the config block being stripped fails 6, and removing the repeated-error table handling fails 2 test-nginx subtests. This also settles the Seer prediction from the previous round empirically -- Set-Cookie demonstrably survives ngx.redirect on the real runtime, which had only been argued from lua-nginx-module's source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
CodeQL flagged the two APISIX test jobs for running with the default token permissions. Set at the workflow level rather than on those jobs alone: every job here only reads the checkout, and a workflow-level default means a job added later inherits the restriction instead of silently getting a broad token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…_IMAGE Two of three suppressed Copilot findings; the third was a wrong claim in the PR description, corrected there rather than in code. `%.apisix/redirect$` also matched application paths ending in the same characters -- /login/foo.apisix/redirect -- and since this plugin is attached host-wide, such a request carrying error=temporarily_unavailable would have been redirected. Anchoring on the leading slash confines it to real callbacks. The gsub deliberately still strips only `.apisix/redirect`, so the target keeps its trailing slash (/login/ rather than /login). Regression case added to all three layers; reverting the anchor fails one test in each. conftest documented APISIX_IT_IMAGE as an image override but never read it, so setting it silently kept testing 3.17.0 -- exactly the false confidence you would not want when rehearsing a version bump. It is honoured now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3mabUCfqcewLTLawoi7e8
…d-connect (#5525) * fix(apisix): send OIDC hosts to a canonical https origin before openid-connect The shared-plugin defaults already carry APISIX's `redirect` plugin with http_to_https, but `redirect` has a lower priority than openid-connect (2599), and APISIX dispatches a phase in descending priority. On every OIDC-protected route the upgrade therefore never runs: openid-connect has already answered. That matters because APISIX derives only a relative redirect_uri and lua-resty-openidc 1.8.0 -- the version APISIX 3.17 pins -- makes it absolute from ngx.var.scheme and ngx.var.http_host. Both are raw connection values, so a plain-HTTP request sends Keycloak an http:// redirect_uri and a request carrying `Host: <host>:443` sends an authority with the port still on it. Keycloak registers bare-host https URIs only and rejects both with error="invalid_redirect_uri", killing the login at the authorization endpoint before any callback exists for the recovery function to rescue. Reproduced against production on both api.learn and nb.learn, so this is not specific to the host the Keycloak log happens to show most. Worse than the failed logins: with the upgrade shadowed, APISIX answers plain -HTTP requests to these hosts with an OIDC session cookie over cleartext and without the Secure attribute. Redirecting is what fixes this rather than setting X-Forwarded-Proto and X-Forwarded-Host. lua-resty-openidc does prefer those headers over the ngx vars, but on this deployment none of Forwarded / X-Forwarded-Proto / X-Forwarded-Host reaches it -- sending each against production leaves the redirect_uri unchanged -- so pinning them would be a no-op that reads like a fix. APISIX keys a plugin config by plugin name, so a route can carry exactly one serverless-pre-function, and both this and the error-callback recovery have to run ahead of openid-connect. They are consequently one plugin with two functions rather than two plugins, which is why the builder is renamed. serverless/init.lua runs `functions` in array order and stops at the first returning a code, so the origin is normalised before recovery decides whether to redirect back into a login flow -- otherwise recovery would target an http:// origin and fail again. Leaving port 80 answering with a redirect is only safe because every ACME ClusterIssuer on the cluster solves via dns01/Route53; an issuer switched to http-01 would need its challenge path carved out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G9yBC9UqoVwiPtxqLNgUD9 * fix(apisix): validate the redirect status, correct the 308 rationale Copilot review on #5525, both points verified against source before acting. ngx.redirect accepts only 301/302/303/307/308 and raises a Lua error on anything else (ngx_http_lua_control.c:209-219). The block this setting travels in is not part of serverless-pre-function's schema, so APISIX will not reject a bad value either -- it would first surface as a 500 on live traffic. The `Literal` annotation alone does not prevent that: this repo's mypy hook runs without the project installed, so a cross-module call resolves to Any and the bad literal passes. Validating in the builder moves the failure to `pulumi preview`, with tests over both the accepted and rejected sets. The 308 rationale was also wrong. APISIX's `redirect` plugin is method-dependent -- 301 for GET/HEAD, 308 for everything else (redirect.lua:208-215) -- so it would not have downgraded a POST, and the previous comment claimed a behavioural fix where there is only a simplification to one uniform status. Corrected in the Lua comment and the docstring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G9yBC9UqoVwiPtxqLNgUD9 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
c8deb03 to
8d48f80
Compare
|
Rebased onto main and resolved the pytest.yml conflict (this branch's apisix-integration/apisix-testnginx jobs vs. main's ci-gate + workflow-scoped permissions in #5567/#5571): kept both, added the two jobs to ci-gate's needs list, applied the contents:read restriction. No review threads were open — the only blocker was the merge-order conflict causing pre-commit.ci's mergeable-check error. |
What are the relevant tickets?
Part of https://github.com/mitodl/hq/issues/12178 — specifically part (1a). Parts (1b) and (2) stay open.
Follows #5413, which added the alerting for these failures.
Description (What does it do?)
Adds
oidc_error_callback_recovery_plugin()and attaches it to the shared plugin configs forapi.learn.mit.edu,mitxonline.mit.edu, andnb.learn.mit.edu.error=temporarily_unavailableand nocode, marking its own eventrestart_after_timeout="true". Its intent is that the client start over.lua-resty-openidctreats anyerrorparameter as fatal, so APISIX serves a 21KB HTTP 500 where the user expected a login form. Nothing retries.serverless-pre-functionin the rewrite phase, where its priority (10000) outranksopenid-connect's (2599) — confirmed by their relative order inAPISIX_DEFAULT_PLUGINS_3_17. It redirects back into the authorization flow before the plugin can fail.<login prefix>/.apisix/redirectand the route serving it is by construction theunauth_action="auth"one, so one attachment per host covers every route group on it — including mit-learn's/loginand/learn/loginprefixes.access_denied(user pressed Cancel) andinvalid_request(a real misconfiguration) keep failing, otherwise the browser spins between the gateway and Keycloak.Why this cause and not the others. #5413 shipped with the caveat that its 500 rate is a rate over requests, not users, and that real impact was unknown. Keycloak's event stream answers it — unlike the gateway access log, it identifies users and sessions. The 1,251 callback 500s/day split three ways:
error=temporarily_unavailable(expired auth session)Keycloak settles the replay question: 4,762 successful
code_to_tokenexchanges against only 7invalid_codeerrors in 24h. If replayed callbacks reached the token endpoint we would see thousands. They die at APISIX's state check instead. That is why the 27.4% / 14.4% callback failure rates in the original audit are not login failure rates, and why only the first row here is worth fixing now.Per-host the split is exact — for api.learn, 327 + 191 + 55 = 573, the measured total.
How can this be tested?
Three layers, each covering something the one below it cannot. All run locally on this branch; the first two are wired as CI jobs alongside the existing
testjob.1. Unit —
uv run pytest tests/ol_infrastructure/components/services/(132 pass)Config rendering, plus
test_apisix_lua.pyexecuting the shipped Lua in aluparuntime against a stubbedngx/apisix.core. Fast feedback on the Lua's logic; it does not prove APISIX accepts anything.2. Integration —
uv run pytest tests/apisix_integration -m integration(11 pass, ~2s)A real APISIX 3.17.0 container in standalone mode, fed the route config built by the actual plugin helper. This is what proves the
oidc_error_recoveryblock survives schema validation and is readable onconf, and thatSet-Cookiesurvivesngx.redirecton the real runtime. Markedintegration, so the repo's existing-m 'not integration'default keeps it out of the fast suite.3. test-nginx —
tests/apisix_testnginx/run.sh(19 subtests pass)APISIX's own harness (
t/APISIX.pm) on the same OpenResty build the gateway runs. Notes for anyone touching it:APISIX.pmboots APISIX's real init path, so without etcd every test fails test-nginx's defaultno [error] in error.logcheck on connection-refused noise. etcd is in the image rather than filtering those lines out, because filtering would also hide errors worth seeing.src/. A second copy beside the.twould be free to drift, and the suite would then pass against Lua we do not ship.t/APISIX.pmand the eight cert fixtures it opens come from the APISIX source tarball at build time — one request, not nine fromraw.githubusercontent.com, which reliably trips its rate limiter (HTTP 429) and made the build a coin flip.Mutation-tested, not assumed. Each layer was verified to fail when the behaviour it covers is broken:
errortable handlingLint:
pre-commitclean on the changed set, including hadolint on the new Dockerfile (verified separately withhadolintdirectly — the repo-wideLint Dockerfilesfailure is pre-existing across other Dockerfiles and is not from this branch).Config-passing chain, checked at every hop against the pinned versions rather than assumed:
ApisixPluginConfigv2 /PluginConfigv1alpha1 CRDsconfigmarkedx-kubernetes-preserve-unknown-fields: trueConfig apiextensionsv1.JSON— raw bytestype Plugins map[string]anyserverless/init.luaphase/functions, noadditionalPropertiesfunc(conf, ctx)Additional Context
Correction — the chronic alert will NOT go quiet, and an earlier version of this description wrongly said it would.
APISIXOIDCCallbackFailureRateChronicfires at >5% over 6h, and its own annotation says it stays up "until the two known causes are fixed". This PR fixes one. Working the measured numbers through:The replay 500s alone keep both hosts above the threshold. Alert silence is therefore not the deployment check. Verify instead that the
temporarily_unavailablecallbacks stop returning 500 — they should go to ~0 while the count of callback requests carryingerror=stays flat:and that the overall rates land near the "after" column above. No rule change is included here — the rule behaves as designed, it is the claim about it that was wrong. Adding a
temporarily_unavailable-specific expression so this cause is independently trackable is a reasonable follow-up if you want it.On prioritising the rest of #12178: on this evidence part (1b) (tolerating replayed codes) is log-noise and alert-inflation cleanup, not a user-facing fix, and is better done after 1a has been observed in production since removing 1a changes the remaining mix. Part (2) (per-issuer route scoping, Rootly INC-10) is untouched and still needs design.
Unrelated finding, filed separately: Keycloak rejects ~12/day of
redirect_uri="http://nb.learn.mit.edu/.apisix/redirect"withinvalid_redirect_uri— notehttp, nothttps. Those fail at the authorization endpoint and never reach the callback, so they are invisible in the numbers above.On the dismissed Seer finding: it predicted (HIGH) that
ngx.header["Set-Cookie"]would be dropped byngx.redirect(), breaking the loop guard. It was dismissed last round on source reading (ngx_http_lua_ngx_redirectpushes Location onto the existingr->headers_out.headerslist and clears nothing). That is now settled empirically instead:test_guard_cookie_survives_ngx_redirectasserts the header on a response a real APISIX generates, andtest_second_failure_falls_through_instead_of_loopingasserts the guard actually engages. Its suggestedheader_filterfix would not have worked —ngx.redirect()terminates the request inrewrite, so that phase never runs for this response.Checklist:
Not merge blockers. The three test layers now cover the plugin's behaviour and APISIX's acceptance of its config; what they cannot reach is the real cluster's CRD → ingress-controller → ADC path and a live Keycloak. Worth confirming against
applications.mitlearn.CIafter this deploys there, before promoting to Production:curl -sSi 'https://api.ci.learn.mit.edu/login/.apisix/redirect?error=temporarily_unavailable&state=x'returns 302 withLocation: /login/andSet-Cookie: apisix_oidc_recovery=1; .... A 500 here means theoidc_error_recoverykey was dropped somewhere between the CRD and APISIX — the one hop no test covers.