Fix OIDC server_error after account delete + re-create - #453
Conversation
After deleting an account and re-creating it, stale session cookies cause oidc-provider to crash in consent.js calling getOIDCScopeEncountered() on an undefined grant. Root cause: account deletion did not expire the browser's OIDC session cookies, so the next auth request carried stale _session cookies that referenced a destroyed session/grant. Fix: - Expire all four oidc-provider session cookies (_session, _session.sig, _session.legacy, _session.legacy.sig) on account deletion, in both the JSON (DELETE /idp/account) and form (POST /idp/account/delete) endpoints. - Add a defensive guard in loadExistingGrant() to return undefined early when session or client is missing, preventing the crash even if stale cookies slip through.
There was a problem hiding this comment.
Pull request overview
Fixes #452, where deleting an account and re-creating it produces an OIDC server_error because the browser still holds session cookies that reference a destroyed session/grant. The fix expires those cookies on both delete endpoints and adds a defensive guard in loadExistingGrant so a missing session/client no longer crashes oidc-provider's consent check.
Changes:
- Add
expireSessionCookies(reply)helper insrc/idp/credentials.jsand call it from bothhandleDeleteAccountandhandleAccountDeleteFormto clear the four_session*cookies. - Short-circuit
loadExistingGrantinsrc/idp/provider.jswhenctx.oidc.sessionorctx.oidc.clientis undefined, and tighten the optional chains around grant/account lookup.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/idp/credentials.js | Adds an expireSessionCookies helper and invokes it from the JSON DELETE and HTML POST account-delete handlers. |
| src/idp/provider.js | Adds an early guard in loadExistingGrant and removes some optional chains now considered redundant. |
Comments suppressed due to low confidence (2)
src/idp/credentials.js:413
- The expired Set-Cookie strings omit
SecureandSameSite=Lax, but the originals are set by oidc-provider with both attributes whenever the issuer is https orNODE_ENV=production(seesrc/idp/provider.jscookies.long/short config at lines 107–121). For pure deletion the browser only matches by (name, domain, path), so this generally works, but the inconsistency means proxies/intermediaries that enforce attribute parity (and some cookie-handling libraries used in tests) can keep the original cookie around. MirroringSecure(when applicable) andSameSite=Laxwould make the clear match the set, and is what the comment ininteractions.jsimplies should happen.
const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly';
reply.header('Set-Cookie', [
`_session=; ${expired}`,
`_session.sig=; ${expired}`,
`_session.legacy=; ${expired}`,
`_session.legacy.sig=; ${expired}`,
]);
src/idp/credentials.js:413
reply.header('Set-Cookie', [...])in Fastify replaces any priorSet-Cookievalue already set on this response. If anything earlier in the request lifecycle (e.g. a future hook, oidc-provider middleware, or the success-rendering path inhandleAccountDeleteForm) sets a cookie on the same reply, it will be silently dropped here. Using an append pattern (e.g.reply.raw.appendHeader/Fastify'sgetHeader('set-cookie')merge, or@fastify/cookie'sclearCookie) would be safer than an unconditional overwrite.
reply.header('Set-Cookie', [
`_session=; ${expired}`,
`_session.sig=; ${expired}`,
`_session.legacy=; ${expired}`,
`_session.legacy.sig=; ${expired}`,
]);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function expireSessionCookies(reply) { | ||
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly'; | ||
| reply.header('Set-Cookie', [ | ||
| `_session=; ${expired}`, | ||
| `_session.sig=; ${expired}`, | ||
| `_session.legacy=; ${expired}`, | ||
| `_session.legacy.sig=; ${expired}`, | ||
| ]); |
| // Expire OIDC session cookies so the browser doesn't send stale | ||
| // references on the next login attempt (#452). | ||
| expireSessionCookies(reply); | ||
|
|
| @@ -275,7 +282,7 @@ export async function createProvider(issuer) { | |||
| } | |||
|
|
|||
| // Auto-approve: create a new grant with all requested scopes | |||
| if (ctx.oidc.session?.accountId && ctx.oidc.client?.clientId) { | |||
| if (ctx.oidc.session.accountId) { | |||
| @@ -275,7 +282,7 @@ export async function createProvider(issuer) { | |||
| } | |||
|
|
|||
| // Auto-approve: create a new grant with all requested scopes | |||
| if (ctx.oidc.session?.accountId && ctx.oidc.client?.clientId) { | |||
| if (ctx.oidc.session.accountId) { | |||
The loadExistingGrant guard alone was insufficient — the crash in consent.js:29 (getOIDCScopeEncountered on undefined grant) happens inside oidc-provider's interaction policy checks, before loadExistingGrant is called. When renderError catches this specific crash, it now expires all session cookies and redirects with _stale_retry=1. The retry starts with a clean session and succeeds. The retry param prevents loops.
- Move duplicate cookie-clearing logic from credentials.js, interactions.js, and provider.js into a shared src/idp/cookies.js module with Fastify and Koa variants. - Add SameSite=Lax to expired cookie attributes to match the original oidc-provider cookie configuration. - Document the optional chain on grantIdFor (stale session stub case).
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax'; | ||
| reply.header('Set-Cookie', SESSION_COOKIE_NAMES.map( | ||
| (name) => `${name}=; ${expired}`, | ||
| )); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax'; | ||
| ctx.set('Set-Cookie', SESSION_COOKIE_NAMES.map( | ||
| (name) => `${name}=; ${expired}`, | ||
| )); |
| export function expireSessionCookies(reply) { | ||
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax'; | ||
| reply.header('Set-Cookie', SESSION_COOKIE_NAMES.map( | ||
| (name) => `${name}=; ${expired}`, | ||
| )); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax'; | ||
| ctx.set('Set-Cookie', SESSION_COOKIE_NAMES.map( | ||
| (name) => `${name}=; ${expired}`, | ||
| )); | ||
| } |
| const isStaleSessionCrash = out.error === 'server_error' && | ||
| error?.message?.includes('getOIDCScopeEncountered'); |
| // starts with a clean session and succeeds. The `_stale_retry` param | ||
| // prevents infinite redirect loops — only try once. | ||
| const isStaleSessionCrash = out.error === 'server_error' && | ||
| error?.message?.includes('getOIDCScopeEncountered'); | ||
| const reqUrl = ctx.req?.originalUrl || ctx.request?.url || ctx.url || ''; | ||
| const alreadyRetried = reqUrl.includes('_stale_retry=1'); | ||
|
|
||
| if (isStaleSessionCrash && !alreadyRetried) { | ||
| expireSessionCookiesKoa(ctx); | ||
| const separator = reqUrl.includes('?') ? '&' : '?'; | ||
| ctx.redirect(`${reqUrl}${separator}_stale_retry=1`); | ||
| return; | ||
| } | ||
|
|
| // Expire OIDC session cookies so the browser doesn't send stale | ||
| // references on the next login attempt (#452). | ||
| expireSessionCookies(reply); |
| reply.header('Set-Cookie', SESSION_COOKIE_NAMES.map( | ||
| (name) => `${name}=; ${expired}`, | ||
| )); |
- Add Secure attribute to expired cookies when request is HTTPS or NODE_ENV=production, mirroring oidc-provider's original cookie config. - Extract buildExpiredHeaders() so both Fastify and Koa helpers share the same attribute string. - Broaden stale session detection in renderError from matching 'getOIDCScopeEncountered' (fragile, tied to oidc-provider internals) to matching TypeError + 'Cannot read properties of undefined' (the general shape of any stale-reference crash).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
src/idp/cookies.js:36
reply.header('Set-Cookie', [...])in Fastify replaces any previously set Set-Cookie header value rather than appending. If any prior code on these account-deletion paths (e.g. error redirects, session middleware, or future additions) sets cookies on the same reply, those would be silently dropped by this call. Consider usingreply.raw.appendHeader('Set-Cookie', cookie)per cookie (or accumulating an existing array) so the expiration headers are additive rather than destructive.
export function expireSessionCookies(reply, request) {
const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production';
reply.header('Set-Cookie', buildExpiredHeaders(secure));
}
src/idp/cookies.js:48
- The
secureflag here is computed fromrequest.protocol === 'https'orNODE_ENV === 'production', but the original cookies were set by oidc-provider withsecure: process.env.NODE_ENV === 'production' || issuer.startsWith('https://')(provider.js:113,120). When the issuer ishttps://...butNODE_ENVis not production and the deletion request itself comes in via plain HTTP (e.g. behind a proxy that strips X-Forwarded-Proto, or local dev), the original cookie will have been set withSecurewhile the expiring header will not. Browsers don't require attribute-match for cookie deletion (only name/path/domain), so this typically still works — but the divergence between "set" and "expire" attribute logic is a maintainability hazard and could matter if cookie attributes (e.g. Domain) are added later. Consider derivingsecurefrom the same condition used in provider.js, or sharing the predicate.
export function expireSessionCookies(reply, request) {
const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production';
reply.header('Set-Cookie', buildExpiredHeaders(secure));
}
/**
* Expire oidc-provider session cookies on a Koa context.
*
* Used by renderError in provider.js for stale-session recovery,
* where the response object is a Koa ctx, not a Fastify reply.
*
* @param {object} ctx - Koa context object
*/
export function expireSessionCookiesKoa(ctx) {
const secure = ctx.secure || process.env.NODE_ENV === 'production';
ctx.set('Set-Cookie', buildExpiredHeaders(secure));
src/idp/provider.js:421
ctx.redirect()without an explicit status defaults to 302. If the originating request was a non-GET (e.g. POST to/idp/authform-post mode, or the token endpoint), the browser will switch to GET on retry and lose the body, producing a confusing failure rather than the intended retry. Additionally, redirecting back tooriginalUrlfor non-browser endpoints (token, userinfo, introspection) makes no sense — those errors are returned as JSON to a programmatic client, which won't follow the redirect or honor Set-Cookie. Consider gating the retry onctx.method === 'GET'and/or on the route being the authorization endpoint.
if (isStaleSessionCrash && !alreadyRetried) {
expireSessionCookiesKoa(ctx);
const separator = reqUrl.includes('?') ? '&' : '?';
ctx.redirect(`${reqUrl}${separator}_stale_retry=1`);
return;
}
src/idp/provider.js:419
- The
_stale_retry=1query parameter is appended to the redirect URL but never stripped afterward. It will end up in browser history, server access logs, and (if the OIDC flow propagates the originalrequest_uri/state somewhere) potentially in downstream redirects too. It's also exposed to the relying party in any error redirects back to the client. Consider documenting this side effect or using a short-lived cookie marker instead of a query param.
const reqUrl = ctx.req?.originalUrl || ctx.request?.url || ctx.url || '';
const alreadyRetried = reqUrl.includes('_stale_retry=1');
if (isStaleSessionCrash && !alreadyRetried) {
expireSessionCookiesKoa(ctx);
const separator = reqUrl.includes('?') ? '&' : '?';
ctx.redirect(`${reqUrl}${separator}_stale_retry=1`);
| const isStaleSessionCrash = out.error === 'server_error' && | ||
| error instanceof TypeError && | ||
| /Cannot read properties of undefined/.test(error?.message); | ||
| const reqUrl = ctx.req?.originalUrl || ctx.request?.url || ctx.url || ''; | ||
| const alreadyRetried = reqUrl.includes('_stale_retry=1'); | ||
|
|
||
| if (isStaleSessionCrash && !alreadyRetried) { | ||
| expireSessionCookiesKoa(ctx); | ||
| const separator = reqUrl.includes('?') ? '&' : '?'; | ||
| ctx.redirect(`${reqUrl}${separator}_stale_retry=1`); | ||
| return; | ||
| } |
| /** | ||
| * Shared cookie helpers for the IdP module. | ||
| * | ||
| * JSS doesn't register @fastify/cookie, so we emit Set-Cookie headers | ||
| * directly. oidc-provider sets four session cookies (_session, | ||
| * _session.sig, _session.legacy, _session.legacy.sig); all four must | ||
| * be expired together to fully clear the browser's session state. | ||
| */ | ||
|
|
||
| const SESSION_COOKIE_NAMES = [ | ||
| '_session', | ||
| '_session.sig', | ||
| '_session.legacy', | ||
| '_session.legacy.sig', | ||
| ]; | ||
|
|
||
| function buildExpiredHeaders(secure) { | ||
| const attrs = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax' | ||
| + (secure ? '; Secure' : ''); | ||
| return SESSION_COOKIE_NAMES.map((name) => `${name}=; ${attrs}`); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Fastify reply. | ||
| * | ||
| * Used by account deletion (credentials.js) and account switching | ||
| * (interactions.js) to prevent stale session references from crashing | ||
| * oidc-provider's consent check (#452). | ||
| * | ||
| * @param {object} reply - Fastify reply object | ||
| * @param {object} [request] - Fastify request (used to detect HTTPS) | ||
| */ | ||
| export function expireSessionCookies(reply, request) { | ||
| const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production'; | ||
| reply.header('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const secure = ctx.secure || process.env.NODE_ENV === 'production'; | ||
| ctx.set('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } |
The redirect recovery only makes sense for browser navigation to /idp/auth (GET). POST requests to token/userinfo/introspection endpoints are programmatic — clients won't follow redirects or honor Set-Cookie, so fall through to the error page instead.
| export function expireSessionCookies(reply, request) { | ||
| const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production'; | ||
| reply.header('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const secure = ctx.secure || process.env.NODE_ENV === 'production'; | ||
| ctx.set('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } |
| const isStaleSessionCrash = out.error === 'server_error' && | ||
| error instanceof TypeError && | ||
| /Cannot read properties of undefined/.test(error?.message); | ||
| const reqUrl = ctx.req?.originalUrl || ctx.request?.url || ctx.url || ''; | ||
| const alreadyRetried = reqUrl.includes('_stale_retry=1'); | ||
|
|
||
| // Only redirect browser GETs (authorization endpoint). POST/token/ | ||
| // userinfo are programmatic — clients won't follow redirects or | ||
| // honor Set-Cookie, so just fall through to the error page. | ||
| if (isStaleSessionCrash && !alreadyRetried && ctx.method === 'GET') { | ||
| expireSessionCookiesKoa(ctx); | ||
| const separator = reqUrl.includes('?') ? '&' : '?'; | ||
| ctx.redirect(`${reqUrl}${separator}_stale_retry=1`); | ||
| return; | ||
| } |
| // starts with a clean session and succeeds. The `_stale_retry` param | ||
| // prevents infinite redirect loops — only try once. | ||
| const isStaleSessionCrash = out.error === 'server_error' && | ||
| error instanceof TypeError && | ||
| /Cannot read properties of undefined/.test(error?.message); | ||
| const reqUrl = ctx.req?.originalUrl || ctx.request?.url || ctx.url || ''; | ||
| const alreadyRetried = reqUrl.includes('_stale_retry=1'); | ||
|
|
||
| // Only redirect browser GETs (authorization endpoint). POST/token/ | ||
| // userinfo are programmatic — clients won't follow redirects or | ||
| // honor Set-Cookie, so just fall through to the error page. | ||
| if (isStaleSessionCrash && !alreadyRetried && ctx.method === 'GET') { | ||
| expireSessionCookiesKoa(ctx); | ||
| const separator = reqUrl.includes('?') ? '&' : '?'; | ||
| ctx.redirect(`${reqUrl}${separator}_stale_retry=1`); |
| /** | ||
| * Shared cookie helpers for the IdP module. | ||
| * | ||
| * JSS doesn't register @fastify/cookie, so we emit Set-Cookie headers | ||
| * directly. oidc-provider sets four session cookies (_session, | ||
| * _session.sig, _session.legacy, _session.legacy.sig); all four must | ||
| * be expired together to fully clear the browser's session state. | ||
| */ | ||
|
|
||
| const SESSION_COOKIE_NAMES = [ | ||
| '_session', | ||
| '_session.sig', | ||
| '_session.legacy', | ||
| '_session.legacy.sig', | ||
| ]; | ||
|
|
||
| function buildExpiredHeaders(secure) { | ||
| const attrs = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax' | ||
| + (secure ? '; Secure' : ''); | ||
| return SESSION_COOKIE_NAMES.map((name) => `${name}=; ${attrs}`); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Fastify reply. | ||
| * | ||
| * Used by account deletion (credentials.js) and account switching | ||
| * (interactions.js) to prevent stale session references from crashing | ||
| * oidc-provider's consent check (#452). | ||
| * | ||
| * @param {object} reply - Fastify reply object | ||
| * @param {object} [request] - Fastify request (used to detect HTTPS) | ||
| */ | ||
| export function expireSessionCookies(reply, request) { | ||
| const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production'; | ||
| reply.header('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const secure = ctx.secure || process.env.NODE_ENV === 'production'; | ||
| ctx.set('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } |
| function buildExpiredHeaders(secure) { | ||
| const attrs = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax' | ||
| + (secure ? '; Secure' : ''); | ||
| return SESSION_COOKIE_NAMES.map((name) => `${name}=; ${attrs}`); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Fastify reply. | ||
| * | ||
| * Used by account deletion (credentials.js) and account switching | ||
| * (interactions.js) to prevent stale session references from crashing | ||
| * oidc-provider's consent check (#452). | ||
| * | ||
| * @param {object} reply - Fastify reply object | ||
| * @param {object} [request] - Fastify request (used to detect HTTPS) | ||
| */ | ||
| export function expireSessionCookies(reply, request) { | ||
| const secure = request?.protocol === 'https' || process.env.NODE_ENV === 'production'; | ||
| reply.header('Set-Cookie', buildExpiredHeaders(secure)); | ||
| } | ||
|
|
||
| /** | ||
| * Expire oidc-provider session cookies on a Koa context. | ||
| * | ||
| * Used by renderError in provider.js for stale-session recovery, | ||
| * where the response object is a Koa ctx, not a Fastify reply. | ||
| * | ||
| * @param {object} ctx - Koa context object | ||
| */ | ||
| export function expireSessionCookiesKoa(ctx) { | ||
| const secure = ctx.secure || process.env.NODE_ENV === 'production'; | ||
| ctx.set('Set-Cookie', buildExpiredHeaders(secure)); |
Summary
Fixes #452
DELETE /idp/accountandPOST /idp/account/delete) did not expire the browser's OIDC session cookies. On the next login attempt, stale_sessioncookies referenced a destroyed session/grant, causingoidc-providerto crash inconsent.jscallinggetOIDCScopeEncountered()onundefined.oidc-providersession cookies (_session,_session.sig,_session.legacy,_session.legacy.sig) viaSet-Cookieheaders withMax-Age=0.loadExistingGrant()inprovider.jsnow returnsundefinedearly whenctx.oidc.sessionorctx.oidc.clientis missing, preventing the crash even if stale cookies slip through other paths.Test plan
server_error_sessioncookies are expired (check Set-Cookie headers in response)DELETE /idp/account) and form (POST /idp/account/delete) endpoints clear cookies