Skip to content

idp: add 'Sign in as a different user' on consent page (#384) - #385

Merged
melvincarvalho merged 6 commits into
gh-pagesfrom
issue-384-switch-account
May 8, 2026
Merged

idp: add 'Sign in as a different user' on consent page (#384)#385
melvincarvalho merged 6 commits into
gh-pagesfrom
issue-384-switch-account

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Closes #384.

What this does

When a user lands on the OIDC Authorize Access consent page (/idp/interaction/<uid> with prompt.name === 'consent'), there's now a small "Sign in as a different user" link next to the "Signed in as " line. Clicking it:

  1. Looks up the in-flight Interaction by uid
  2. Destroys the oidc-provider Session via provider.Session.findByUid(...).destroy()
  3. Mutates the interaction's prompt back to { name: 'login' } and drops the session reference (prompt and session are both in Interaction's IN_PAYLOAD, so the mutation persists)
  4. Clears the user-agent's _session / _session.legacy / _session.sig cookies
  5. Redirects back to /idp/interaction/<uid>handleInteractionGet now sees prompt.name === 'login' and renders the login page
  6. After successful login, the standard resume flow at /idp/auth/<uid> picks up the original authz params (state, nonce, PKCE challenge, redirect_uri) untouched

Re-using the same interaction uid (rather than aborting and issuing a fresh /idp/auth) preserves the requesting app's flow — its state / redirect_uri / etc. survive the identity switch, and we don't risk loadExistingGrant silently auto-approving from another stale grant.

Files changed (~72 insertions across 3)

File Change
src/idp/interactions.js New handleSwitchAccount() (~50 LOC including doc + error handling)
src/idp/index.js Import + register POST /idp/interaction/:uid/switch (~7 LOC)
src/idp/views.js Add the link inside the "Signed in as " paragraph in consentPage (~10 LOC, inline-styled to keep all changes in one file)

Local verification

  • npm test -- idp.test.js48 / 48 pass, no regressions
  • Boot smoke test on a single-user pod (--idp):
    • POST /idp/interaction/nonexistent/switch404 (cleaner than abort's 500 on the same input)
    • /idp landing renders 200, no boot-time errors
  • Static analysis of the oidc-provider 9.6 internals:
    • Session.findByUid + .destroy() — confirmed at node_modules/oidc-provider/lib/models/session.js:41,112
    • IN_PAYLOAD contains prompt and sessionnode_modules/oidc-provider/lib/models/interaction.js:60-72
    • Resume action only checks result.login.accountId, doesn't re-validate prompts — lib/actions/authorization/resume.js:94-104

Out of scope / possible follow-ups

  • CSRF tokens on the consent forms. The new switch button matches the existing confirm/abort threat model — the interaction uid is the unguessable token. A CSRF audit across all three forms could be a follow-up.
  • Multi-account picker (vs a hard switch) for users with several stored sessions. v2 territory.

Test plan

  • npm test passes in CI
  • Walk a real OIDC client (e.g. pilot from 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
  • After "Sign in as a different user", browser cookies for _session* are cleared

Refs

  • node_modules/oidc-provider/lib/models/session.js
  • node_modules/oidc-provider/lib/models/interaction.js
  • node_modules/oidc-provider/lib/actions/authorization/resume.js
  • src/idp/interactions.js (handleAbort handler — same shape as the new handler)

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/switch handler that destroys the current OIDC session, clears _session* cookies, rewrites the interaction prompt to login, 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.

Comment thread src/idp/interactions.js
Comment on lines +316 to +338
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/interactions.js
Comment on lines +349 to +352
} catch (err) {
request.log.error(err, 'Switch-account error');
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/index.js
Comment on lines +328 to +333
// 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);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/views.js Outdated
Comment on lines +507 to +513
<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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0b8bf9f: changed `

` to `

`. Browsers were implicitly closing the `

` before the inner `

` (since `

` only allows phrasing content), breaking the intended single-line flex layout. `

` keeps everything in one element.

…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread test/idp.test.js Outdated
Comment on lines +234 to +235
const setCookie = res.headers.get('set-cookie') || '';
assert.match(setCookie, /_session/);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/interactions.js Outdated
`_session.sig=; ${expired}`,
]);

return reply.redirect(`/idp/interaction/${uid}`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/idp/interactions.js Outdated
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}`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/idp/interactions.js Outdated
// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a0b7bf: switched to `reply.redirect(303, `/idp/interaction/${uid}`)` to match the rest of the codebase. Comment updated to cite the precedents.

Comment thread test/idp.test.js Outdated
// 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?.() || [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread test/idp.test.js Outdated
Comment on lines +238 to +243
// 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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@melvincarvalho
melvincarvalho merged commit b03192b into gh-pages May 8, 2026
4 checks passed
@melvincarvalho
melvincarvalho deleted the issue-384-switch-account branch May 8, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth flow: add logout / switch-account option on Authorize Access page

2 participants