Skip to content

Fix OIDC server_error after account delete + re-create - #453

Merged
melvincarvalho merged 5 commits into
gh-pagesfrom
issue-452-oidc-stale-session-crash
May 14, 2026
Merged

Fix OIDC server_error after account delete + re-create#453
melvincarvalho merged 5 commits into
gh-pagesfrom
issue-452-oidc-stale-session-crash

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Summary

Fixes #452

  • Root cause: Account deletion (DELETE /idp/account and POST /idp/account/delete) did not expire the browser's OIDC session cookies. On the next login attempt, stale _session cookies referenced a destroyed session/grant, causing oidc-provider to crash in consent.js calling getOIDCScopeEncountered() on undefined.
  • Cookie expiration: Both the JSON and form-based delete endpoints now expire all four oidc-provider session cookies (_session, _session.sig, _session.legacy, _session.legacy.sig) via Set-Cookie headers with Max-Age=0.
  • Defensive guard: loadExistingGrant() in provider.js now returns undefined early when ctx.oidc.session or ctx.oidc.client is missing, preventing the crash even if stale cookies slip through other paths.

Test plan

  • Delete an account, re-create it with the same username, and verify login succeeds without server_error
  • Verify that after account deletion, the browser's _session cookies are expired (check Set-Cookie headers in response)
  • Verify normal login/consent flow still works for existing accounts
  • Verify that both JSON (DELETE /idp/account) and form (POST /idp/account/delete) endpoints clear cookies

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.

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

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 in src/idp/credentials.js and call it from both handleDeleteAccount and handleAccountDeleteForm to clear the four _session* cookies.
  • Short-circuit loadExistingGrant in src/idp/provider.js when ctx.oidc.session or ctx.oidc.client is 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 Secure and SameSite=Lax, but the originals are set by oidc-provider with both attributes whenever the issuer is https or NODE_ENV=production (see src/idp/provider.js cookies.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. Mirroring Secure (when applicable) and SameSite=Lax would make the clear match the set, and is what the comment in interactions.js implies 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 prior Set-Cookie value 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 in handleAccountDeleteForm) sets a cookie on the same reply, it will be silently dropped here. Using an append pattern (e.g. reply.raw.appendHeader/Fastify's getHeader('set-cookie') merge, or @fastify/cookie's clearCookie) 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.

Comment thread src/idp/credentials.js Outdated
Comment on lines +406 to +413
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}`,
]);
Comment thread src/idp/credentials.js
Comment on lines +367 to +370
// Expire OIDC session cookies so the browser doesn't send stale
// references on the next login attempt (#452).
expireSessionCookies(reply);

Comment thread src/idp/provider.js
Comment on lines 266 to +285
@@ -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) {
Comment thread src/idp/provider.js
Comment on lines 275 to +285
@@ -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).

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 6 comments.

Comment thread src/idp/cookies.js Outdated
Comment on lines +27 to +45
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}`,
));
Comment thread src/idp/cookies.js Outdated
Comment on lines +26 to +46
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}`,
));
}
Comment thread src/idp/provider.js Outdated
Comment on lines +410 to +411
const isStaleSessionCrash = out.error === 'server_error' &&
error?.message?.includes('getOIDCScopeEncountered');
Comment thread src/idp/provider.js
Comment on lines +408 to +421
// 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;
}

Comment thread src/idp/credentials.js Outdated
Comment on lines +368 to +370
// Expire OIDC session cookies so the browser doesn't send stale
// references on the next login attempt (#452).
expireSessionCookies(reply);
Comment thread src/idp/cookies.js Outdated
Comment on lines +28 to +30
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).

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.

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 using reply.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 secure flag here is computed from request.protocol === 'https' or NODE_ENV === 'production', but the original cookies were set by oidc-provider with secure: process.env.NODE_ENV === 'production' || issuer.startsWith('https://') (provider.js:113,120). When the issuer is https://... but NODE_ENV is 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 with Secure while 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 deriving secure from 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/auth form-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 to originalUrl for 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 on ctx.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=1 query 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 original request_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`);

Comment thread src/idp/provider.js
Comment on lines +410 to +421
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;
}
Comment thread src/idp/cookies.js
Comment on lines +1 to +49
/**
* 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.

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 5 comments.

Comment thread src/idp/cookies.js
Comment on lines +33 to +49
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));
}
Comment thread src/idp/provider.js
Comment on lines +410 to +424
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;
}
Comment thread src/idp/provider.js
Comment on lines +408 to +422
// 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`);
Comment thread src/idp/cookies.js
Comment on lines +1 to +49
/**
* 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));
}
Comment thread src/idp/cookies.js
Comment on lines +17 to +48
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));
@melvincarvalho
melvincarvalho merged commit 0861c0b into gh-pages May 14, 2026
5 checks passed
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.

OIDC server_error after account delete + re-create (stale session)

2 participants