idp: add 'Sign in as a different user' on consent page (#384) - #385
Conversation
Adds a switch-account link to the OIDC Authorize Access page next to
the "Signed in as <email>" line, so users with multiple identities
don't have to manually clear browser state to switch.
Implementation (~70 LOC across 3 files):
- src/idp/interactions.js: new handleSwitchAccount() — looks up the
in-flight Interaction, destroys the bound oidc-provider Session via
provider.Session.findByUid(...).destroy(), mutates the interaction's
prompt back to {name:'login'} (preserving the original authz params),
clears the user-agent's session cookies, and redirects to the same
/idp/interaction/:uid URL. handleInteractionGet then re-renders as
the login page; the resume action picks up the original client_id /
redirect_uri / state on next login. Returns 404 on missing
interaction (cleaner than abort's 500).
- src/idp/index.js: import + register POST /idp/interaction/:uid/switch.
- src/idp/views.js: small inline-style link inside the "Signed in as …"
paragraph on consentPage. Uses an unstyled <button> in a <form> so
it's a real POST (no GET-via-link CSRF surface).
Why re-use the same interaction uid (rather than ending the session and
issuing a fresh /idp/auth) — preserves the OIDC params (state, nonce,
PKCE challenge, redirect_uri) so the requesting app's flow continues
unchanged. New /idp/auth would also re-trigger loadExistingGrant which
might silently auto-approve from another stale grant.
Verified:
- 48/48 idp.test.js tests pass (no regressions to existing flows)
- Boot smoke test: /idp/interaction/nonexistent/switch returns 404,
/idp landing renders 200
- Static analysis of the consent flow matches the agent's research:
Session.findByUid + .destroy() in oidc-provider 9.6 work cleanly,
`prompt` and `session` are in Interaction's IN_PAYLOAD so the
mutation persists through the filesystem adapter.
Out of scope (follow-ups worth filing if pushed):
- CSRF tokens on consent forms (matches existing confirm/abort
behavior; the interaction uid is the unguessable token)
- Multi-account picker (vs a hard switch) for users with >2 stored
sessions
There was a problem hiding this comment.
Pull request overview
Adds a “Sign in as a different user” affordance to the IdP consent (Authorize Access) page, enabling users to reset the in-flight OIDC interaction back to the login prompt while preserving the original authorization request parameters (state/nonce/PKCE/redirect_uri).
Changes:
- Update consent page UI to show a “Sign in as a different user” action when an account is present.
- Add
POST /idp/interaction/:uid/switchhandler that destroys the current OIDC session, clears_session*cookies, rewrites the interaction prompt tologin, and redirects back to/idp/interaction/:uid. - Register the new switch route in the IdP Fastify plugin.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/idp/views.js |
Adds the “Sign in as a different user” control on the consent page. |
src/idp/interactions.js |
Implements the switch-account POST handler that resets interaction/session state and clears cookies. |
src/idp/index.js |
Wires up the new /idp/interaction/:uid/switch route. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const interaction = await provider.Interaction.find(uid); | ||
| if (!interaction) { | ||
| return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This interaction may have expired. Try signing in again from your app.')); | ||
| } | ||
|
|
||
| // Destroy the bound session so the new login starts cold. The cookie | ||
| // becomes a stale reference; oidc-provider's Session.get treats a | ||
| // missing session blob as "new browser", which is the shape we want. | ||
| if (interaction.session?.uid) { | ||
| const sess = await provider.Session.findByUid(interaction.session.uid); | ||
| if (sess) await sess.destroy(); | ||
| } | ||
|
|
||
| // Reset the interaction back to the login prompt, dropping the | ||
| // session reference. `prompt` and `session` are both in the | ||
| // oidc-provider Interaction IN_PAYLOAD allowlist, so this persists | ||
| // through the adapter. Original `params` (client_id, redirect_uri, | ||
| // state, etc.) are untouched, so resume picks them up after login. | ||
| interaction.session = undefined; | ||
| interaction.prompt = { name: 'login', reasons: ['no_session'], details: {} }; | ||
| interaction.lastError = undefined; | ||
| const ttl = Math.max(1, interaction.exp - Math.floor(Date.now() / 1000)); | ||
| await interaction.save(ttl); |
There was a problem hiding this comment.
Fixed in 0b8bf9f: handleSwitchAccount now returns 400 unless `interaction.prompt?.name === 'consent'`. Also clears `interaction.result` (in IN_PAYLOAD too) so a stale `result.login` from a prior identity can't influence the next resume.
| } catch (err) { | ||
| request.log.error(err, 'Switch-account error'); | ||
| return reply.code(500).type('text/html').send(errorPage('Error', err.message)); | ||
| } |
There was a problem hiding this comment.
Fixed in 0b8bf9f: 500 path now returns a generic 'Something went wrong' message. Full error is already going to `request.log.error`; nothing leaks to the browser.
| // POST "Sign in as a different user" (#384) — destroys the OIDC | ||
| // session and bounces back to the login prompt while preserving the | ||
| // in-flight authz request. | ||
| fastify.post('/idp/interaction/:uid/switch', async (request, reply) => { | ||
| return handleSwitchAccount(request, reply, provider); | ||
| }); |
There was a problem hiding this comment.
Fixed in 0b8bf9f: added a `Switch account on consent (#384)` describe block in test/idp.test.js with three tests — 302 + interaction mutated to login + cookies cleared on success, 400 on non-consent prompt with interaction untouched, 404 on unknown uid. Tests write synthetic Interaction records to the filesystem adapter so they don't need a full OIDC client walkthrough. 51/51 idp.test.js tests pass.\n\nBonus: the test suite caught a real bug — `reply.clearCookie` doesn't exist on JSS's reply (no @fastify/cookie registered). Replaced with `reply.header('Set-Cookie', [...])` which Fastify emits as multiple Set-Cookie response headers.
| <p style="display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap;"> | ||
| <span>Signed in as <strong>${escapeHtml(account.email)}</strong></span> | ||
| <span style="color: #94a3b8;">·</span> | ||
| <form method="POST" action="/idp/interaction/${uid}/switch" style="display: inline; margin: 0;"> | ||
| <button type="submit" style="background: none; border: 0; padding: 0; color: #2563eb; font: inherit; cursor: pointer; text-decoration: underline;">Sign in as a different user</button> | ||
| </form> | ||
| </p> |
There was a problem hiding this comment.
Fixed in 0b8bf9f: changed `
` to `
` before the inner `
` (since `` only allows phrasing content), breaking the intended single-line flex layout. `
…g, HTML, tests
Four fixes per the review pass:
1. handleSwitchAccount now validates interaction.prompt.name === 'consent'
before mutating. Returns 400 otherwise. Prevents a crafted POST from
corrupting an in-flight non-consent interaction (login, passkey, etc.).
Also clears interaction.result so a stale result.login from a prior
identity can't influence the next resume.
2. The 500 error path no longer surfaces err.message to the browser.
Adapter / fs / oidc-provider errors can leak file paths and other
internals; full error is already going to request.log.error. Generic
"Something went wrong" is what the user sees.
3. <p> with <form> inside it was invalid HTML — browsers implicitly close
the <p> before the <form>, breaking the intended inline layout. The
"Signed in as <email> · Sign in as a different user" element is now
a <div> with the same flex styling.
4. Added test coverage in test/idp.test.js — three tests for the new
route: success path (302 + interaction mutated to login + cookies
cleared), 400 on non-consent prompt (interaction untouched), 404
on unknown uid. Tests write synthetic Interaction records directly
to the filesystem adapter at <DATA_DIR>/.idp/interaction/<uid>.json
so they don't have to walk a full OIDC client flow.
Also caught and fixed: reply.clearCookie() doesn't exist (JSS doesn't
register @fastify/cookie). Replaced with reply.header('Set-Cookie',
[...expired cookie strings]) which Fastify emits as multiple Set-Cookie
headers.
51/51 idp.test.js tests pass.
| const setCookie = res.headers.get('set-cookie') || ''; | ||
| assert.match(setCookie, /_session/); |
There was a problem hiding this comment.
Fixed in ae7a029: test now uses `res.headers.getSetCookie()` (array-returning) and asserts there are at least 3 Set-Cookie headers, one of which clears `_session`, and every one of them carries an expiration (`Max-Age=0` or 1970 `Expires`). The standard `.get()` was unreliable per the Fetch spec's forbidden-header-name rule.
| `_session.sig=; ${expired}`, | ||
| ]); | ||
|
|
||
| return reply.redirect(`/idp/interaction/${uid}`); |
There was a problem hiding this comment.
Fixed in ae7a029: `reply.redirect(..., 303)`. 302 leaves the UA free to repeat the POST to the Location, which on this handler would loop into `/switch` again. 303 forces GET. Test asserts the 303 directly so the contract is encoded.
Two more legit fixes: 1. Redirect changed from 302 to 303 (See Other). 302 leaves the UA free to repeat the POST to the Location target, which on this handler would re-trigger /switch in a loop. 303 unambiguously forces GET on the Location, which is what we want. 2. Test uses res.headers.getSetCookie() instead of headers.get(). Per the Fetch spec, Set-Cookie is a "forbidden header name" on the standard .get() — return value is implementation-defined and may collapse multiple Set-Cookie response headers into a single string or return null. .getSetCookie() returns an array, which is what we want for asserting on multiple cookie clears. Test asserts now stricter: - status === 303 - >= 3 Set-Cookie headers (one per cleared cookie name) - one of them clears _session - every Set-Cookie carries an expiration (Max-Age=0 or 1970 Expires) 51/51 idp.test.js tests pass.
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly'; | ||
| reply.header('Set-Cookie', [ | ||
| `_session=; ${expired}`, | ||
| `_session.legacy=; ${expired}`, |
There was a problem hiding this comment.
Fixed in fd77a66: _session.legacy.sig added to the cookie expiration list. The IdP runs with signed cookies (provider.js cookies.long.signed = true per the agent's research), so each session cookie has a paired .sig; we were clearing 3 of the 4 expected names. Test now asserts >= 4 Set-Cookie headers and verifies every one of _session, _session.sig, _session.legacy, _session.legacy.sig is among them. 51/51 idp.test.js tests pass.
The IdP config uses signed cookies (provider.js cookies.long.signed = true), so every session cookie name has a paired `.sig`. We were already clearing _session and _session.sig and _session.legacy, but missed _session.legacy.sig. Without it, the browser would carry a lingering signature cookie until the normal maxAge. Test now asserts >= 4 Set-Cookie headers and verifies every one of the four expected names is among them. 51/51 idp.test.js tests pass.
| // 303 See Other — explicitly forces the UA to issue GET on the | ||
| // Location target. 302 leaves it ambiguous (and some legacy UAs | ||
| // repeat the POST), which would re-trigger this handler in a loop. | ||
| return reply.redirect(`/idp/interaction/${uid}`, 303); |
There was a problem hiding this comment.
Fixed in 2a0b7bf: switched to `reply.redirect(303, `/idp/interaction/${uid}`)` to match the rest of the codebase. Comment updated to cite the precedents.
| // Cookies should be cleared so the user's UA forgets the prior | ||
| // session. Per the Fetch spec, Set-Cookie is a forbidden header | ||
| // name on .get(); use the array-returning getSetCookie() helper. | ||
| const setCookies = res.headers.getSetCookie?.() || []; |
There was a problem hiding this comment.
Fair point — fixed in 2a0b7bf. The test now does `assert.strictEqual(typeof res.headers.getSetCookie, 'function', ...)` before calling it, so a Node version without the method (pre-19.7) hard-fails the test with a clear message rather than silently asserting on an empty array.
…issing getSetCookie Two fixes: 1. reply.redirect now uses (statusCode, url) — matches the rest of the codebase (src/server.js:637, src/tunnel/index.js:222) and avoids relying on the alternate Fastify overload (which is removed in Fastify 5). 2. Test now hard-fails when Headers.getSetCookie() is missing instead of silently asserting on []. The previous `getSetCookie?.() || []` pattern was a phantom fallback: if the method were missing, the array would be empty and the next assertion (length >= 4) would fail with "got 0" — but the failure mode would point at "no cookies were sent" rather than "your Node is too old to inspect Set-Cookies via fetch." Explicit assertion clarifies which is which. 51/51 idp.test.js tests pass.
| // Available on Node 19.7+ (we don't fall back silently — if the | ||
| // method is missing the test should fail loudly so we know to | ||
| // adjust rather than passing on an empty array). | ||
| assert.strictEqual(typeof res.headers.getSetCookie, 'function', | ||
| 'Headers.getSetCookie() unavailable — needs Node 19.7+; bump engines.node or adjust this test'); | ||
| const setCookies = res.headers.getSetCookie(); |
There was a problem hiding this comment.
Real point. Fixed in a44ba9b: switched the cookie-inspection test to use node:http directly via a small rawPost() helper. http.request gives res.headers['set-cookie'] as a real array on every Node version that has node:http (all of them), so engines.node >= 18 stays honest with no version-gated branches. Test suite passes on Node 18+ as the package promises.
The previous test asserted Headers.getSetCookie() exists, but package.json declares engines.node >= 18 and getSetCookie is only on Node 19.7+. Failing loud was an improvement but still meant the test suite was broken on a declared-supported Node. Switched the cookie-inspection test to use node:http directly via a small rawPost() helper. http.request gives res.headers['set-cookie'] as a real array on every Node version that has node:http (i.e. all of them), so engines.node >= 18 stays honest with no version-gated test branches. Other tests in the block still use fetch — they don't need raw header access and the existing pattern is preserved. 51/51 idp.test.js tests pass.
Closes #384.
What this does
When a user lands on the OIDC Authorize Access consent page (
/idp/interaction/<uid>withprompt.name === 'consent'), there's now a small "Sign in as a different user" link next to the "Signed in as " line. Clicking it:provider.Session.findByUid(...).destroy()promptback to{ name: 'login' }and drops thesessionreference (promptandsessionare both in Interaction'sIN_PAYLOAD, so the mutation persists)_session/_session.legacy/_session.sigcookies/idp/interaction/<uid>—handleInteractionGetnow seesprompt.name === 'login'and renders the login page/idp/auth/<uid>picks up the original authz params (state, nonce, PKCE challenge, redirect_uri) untouchedRe-using the same interaction
uid(rather than aborting and issuing a fresh/idp/auth) preserves the requesting app's flow — itsstate/redirect_uri/ etc. survive the identity switch, and we don't riskloadExistingGrantsilently auto-approving from another stale grant.Files changed (~72 insertions across 3)
src/idp/interactions.jshandleSwitchAccount()(~50 LOC including doc + error handling)src/idp/index.jsPOST /idp/interaction/:uid/switch(~7 LOC)src/idp/views.jsconsentPage(~10 LOC, inline-styled to keep all changes in one file)Local verification
npm test -- idp.test.js→ 48 / 48 pass, no regressions--idp):POST /idp/interaction/nonexistent/switch→ 404 (cleaner than abort's 500 on the same input)/idplanding renders 200, no boot-time errorsSession.findByUid+.destroy()— confirmed atnode_modules/oidc-provider/lib/models/session.js:41,112IN_PAYLOADcontainspromptandsession—node_modules/oidc-provider/lib/models/interaction.js:60-72result.login.accountId, doesn't re-validate prompts —lib/actions/authorization/resume.js:94-104Out of scope / possible follow-ups
uidis the unguessable token. A CSRF audit across all three forms could be a follow-up.Test plan
npm testpasses in CIpilotfrom the IDP landing) through: login as user A → consent page renders → click "Sign in as a different user" → login form appears → log in as user B → consent page now shows user B's email → Allow → app receives auth code bound to user B_session*are clearedRefs
node_modules/oidc-provider/lib/models/session.jsnode_modules/oidc-provider/lib/models/interaction.jsnode_modules/oidc-provider/lib/actions/authorization/resume.jssrc/idp/interactions.js(handleAbort handler — same shape as the new handler)