fix(idp): login error survives the redirect — ride in lastSubmission — closes #514 - #554
Conversation
…514) The failed-login path set `interaction.lastError`, saved, and redirected back to the form. Every piece of the wiring looked right — views.js renders the error div, the GET re-render passes the value, the setters fire — but the error never appeared. Root cause: oidc-provider's Interaction.save() persists ONLY the fields in the model's IN_PAYLOAD list (lib/models/interaction.js), and `lastError` isn't one of them. The property survived in memory, was silently dropped on save, and the redirected GET re-rendered a pristine form. Users retried blind; the issue author lost hours to an imagined OIDC regression (#513) because the IdP refused to surface a wrong-password error. Fix: the message rides in `lastSubmission` — the IN_PAYLOAD slot oidc-provider designates for exactly this form re-render state. All four sites updated (two setters, the GET reader, and the switch-account clear), with the IN_PAYLOAD gotcha documented at the first setter so the next foreign-property bug doesn't take hours to spot. Verified live before and after: the issue's repro (wrong password on a single-user IdP) now renders `<div class="error">Invalid username or password</div>` on the re-rendered form. Regression test drives the real OIDC interaction flow over HTTP (dynamic client registration → /idp/auth → interaction redirect → POST bad credentials → follow redirect) with manual cookie threading and asserts the error div. 948/948 passing. Closes #514.
There was a problem hiding this comment.
Pull request overview
Fixes IdP login error messages being lost across the POST→redirect→GET cycle by storing the error in Interaction.lastSubmission (an oidc-provider persisted payload field) and adds a regression test for the full interaction flow over HTTP.
Changes:
- Read login error state from
interaction.lastSubmission?.lastErrorwhen rendering the login page. - Write login validation/authentication errors into
interaction.lastSubmission(instead of a non-persistedinteraction.lastError) and clear it on switch-account. - Add an end-to-end test that reproduces the failed-login redirect and asserts the error banner is rendered.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/idp/interactions.js |
Persists login error state through oidc-provider interaction saves via lastSubmission and clears it on account switch. |
test/idp-login-error.test.js |
New regression test covering the real OIDC interaction redirect loop and ensuring the login error banner appears. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Collect cookies from a response and merge into a name→value jar. | ||
| function absorbCookies(jar, res) { | ||
| for (const c of res.headers.getSetCookie?.() || []) { | ||
| const [pair] = c.split(';'); | ||
| const eq = pair.indexOf('='); | ||
| if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); | ||
| } |
There was a problem hiding this comment.
Fixed in a7ea839 — with an explicit guard rather than a quiet fallback: the test detects Headers.getSetCookie support up front and skips with a self-documenting reason on older Node. Context that makes the skip free: the IdP itself cannot run on Node 18 at all (oidc-provider uses Array#toReversed and crypto.hash — #523, engines bump deferred as #541), so every IdP test is already broken on those runtimes; this one now states why instead of failing at a confusing distance. On supported runtimes the jar call is now direct (no optional chaining) since the guard owns the decision. Suite still green.
…-cookie failure Copilot: Headers.getSetCookie() landed in Node 18.15/19.7, but engines still declares >=18.0.0 (bump deferred — #541). The previous optional-chaining fallback would collect zero cookies on older Node and fail the test at a confusing distance. Fix: detect support up front and skip with a self-documenting reason. Skipping costs nothing on those runtimes — the IdP itself cannot run on Node 18 at all (oidc-provider uses Array#toReversed and crypto.hash, #523), so every IdP test is already broken there; this one now says WHY instead of failing mysteriously. The jar call is now direct (no optional chaining) since the guard owns the decision.
Eight PRs merged since 0.0.206 — IdP hardening, protocol conformance, and the de-Googled-phone (#46) arc: IdP / auth - #558 passkey login degrades cleanly on stale WebViews / insecure contexts instead of crashing — drops a redundant browser crypto.randomUUID and guards both ceremonies on secure-context + WebAuthn (closes #556) - #554 IdP login-form error now survives the POST→redirect→GET cycle (rode in a non-persisted field that Interaction.save() dropped); failed sign-ins show the error instead of a silent re-render (closes #514) - #551 RFC 9207 'iss' authorization-response param normalized to match the discovery issuer, so strict OIDC clients (solid-oidc) complete sign-in (closes #524) Tunnel - #555 opt-in, per-tunnel credential passthrough (Cookie / Authorization / Set-Cookie) so authenticated access works through a tunnel; the relay's own IdP session cookies are isolated from the tunnel client (closes #530) Content negotiation / git - #553 HEAD now mirrors GET's negotiated Content-Type / Content-Length / Cache-Control for files (RFC 9110 §9.3.2 parity) (closes #552) - #550 git WAC preHandler 401/402/403 responses carry the git CORS headers, so browser git clients see the status, not a CORS error (closes #548) - #549 first HTTP-contract coverage for the git handler + fixes a DATA_ROOT test-pollution bug (closes #375) Docs / metadata - #547 README tagline + npm keywords surface the agentic positioning (closes #406)
Closes #514.
Bug — and why it was invisible in the source
Every piece of the error wiring looked correct: the failed-login path set
interaction.lastErrorand saved (interactions.js:178/186), the GET re-render passed it to the view (:98), andloginPage()renders the error div (views.js:448). Yet the live repro confirmed the issue: wrong password → redirect → pristine form, no error.Root cause: oidc-provider's
Interaction.save()persists only the fields in the model'sIN_PAYLOADlist (node_modules/oidc-provider/lib/models/interaction.js:57).lastErroris a foreign property — it survived in memory, was silently dropped on save, and the redirected GET loaded a fresh instance without it.Fix
The message rides in
lastSubmission— theIN_PAYLOADslot oidc-provider designates for exactly this form re-render state:interactions.js:178(missing fields)interaction.lastSubmission = { lastError: … }+ the IN_PAYLOAD gotcha documented in a commentinteractions.js:186(bad credentials)interactions.js:98(GET re-render)interaction.lastSubmission?.lastErrorinteractions.js:407(switch-account clear)lastSubmissionVerification
<div class="error">Invalid username or password</div>renders.test/idp-login-error.test.js): drives the real OIDC interaction flow over HTTP — dynamic client registration →/idp/auth→ interaction redirect → POST bad credentials → follow redirect — with manual cookie threading, asserting the error div appears. DATA_ROOT snapshot/restore per house pattern.On the issue's "sweep other pages" note
The register flow renders
registerPage(uid, error, …)directly in the same response (no save/redirect cycle), so it doesn't have this bug — the persistence trap only fires when error state must survive a round-trip throughInteraction.save(). The login form was the only such site (thelastErrorgrep now returns zero hits).