Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/idp/cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 on lines +17 to +48
}
Comment on lines +1 to +49
Comment on lines +33 to +49
Comment on lines +1 to +49
9 changes: 9 additions & 0 deletions src/idp/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { authenticate, findByUsername, findByWebId, updatePassword, verifyPasswo
import { getJwks } from './keys.js';
import { getWebIdFromRequestAsync } from '../auth/token.js';
import { accountDeletePage } from './views.js';
import { expireSessionCookies } from './cookies.js';

/**
* Handle POST /idp/credentials
Expand Down Expand Up @@ -364,6 +365,10 @@ export async function handleDeleteAccount(request, reply, options = {}) {
// and rationale (#391 pass 2 / pass 3).
const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);

// Expire OIDC session cookies so the browser doesn't send stale
// references on the next login attempt (#452).
expireSessionCookies(reply, request);

Comment on lines +368 to +371
reply.header('Cache-Control', 'no-store');
reply.header('Pragma', 'no-cache');
return {
Expand Down Expand Up @@ -547,6 +552,10 @@ export async function handleAccountDeleteForm(request, reply, options = {}) {

const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);

// Expire OIDC session cookies so the browser doesn't send stale
// references on the next login attempt (#452).
expireSessionCookies(reply, request);

// If the user asked for a purge but it didn't run (fs.remove threw,
// path-relative check rejected, etc.), surface that on the success
// page. Account deletion succeeded — don't roll that back — but the
Expand Down
19 changes: 4 additions & 15 deletions src/idp/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as storage from '../storage/filesystem.js';
import { createPodStructure } from '../handlers/container.js';
import { validateInvite } from './invites.js';
import { verifyNostrAuth, getNostrPubkey, verifyNostrPubkeyAgainstWebId } from '../auth/nostr.js';
import { expireSessionCookies } from './cookies.js';

// Security: Maximum body size for IdP form submissions (1MB)
const MAX_BODY_SIZE = 1024 * 1024;
Expand Down Expand Up @@ -348,22 +349,10 @@ export async function handleSwitchAccount(request, reply, provider) {
const ttl = Math.max(1, interaction.exp - Math.floor(Date.now() / 1000));
await interaction.save(ttl);

// Clear the user-agent's session cookie too. The IdP runs with
// signed cookies (provider.js cookies.long.signed = true), so each
// session cookie has a paired `.sig`. The `.legacy` variant is
// created during identifier rotation and likewise has its own
// `.sig`. Clearing all four keeps the browser fully tidy. JSS
// doesn't register @fastify/cookie, so we emit Set-Cookie headers
// directly with an expired Expires + Max-Age=0. Server-side state
// is already gone via session.destroy() above — these expirations
// Clear the user-agent's session cookies. Server-side state is
// already gone via session.destroy() above — these expirations
// are belt-and-suspenders.
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}`,
]);
expireSessionCookies(reply, request);

// 303 See Other — explicitly forces the UA to issue GET on the
// Location target. 302 leaves it ambiguous (and some legacy UAs
Expand Down
36 changes: 33 additions & 3 deletions src/idp/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAdapter } from './adapter.js';
import { getJwks, getCookieKeys } from './keys.js';
import { getAccountForProvider } from './accounts.js';
import { validateExternalUrl } from '../utils/ssrf.js';
import { expireSessionCookiesKoa } from './cookies.js';

// Cache for fetched client documents
const clientDocumentCache = new Map();
Expand Down Expand Up @@ -264,8 +265,16 @@ export async function createProvider(issuer) {
// Auto-approve consent by loading/creating grants automatically
// This skips the consent prompt for all clients (appropriate for test/dev servers)
loadExistingGrant: async (ctx) => {
// Check if there's an existing grant for this client/account pair
const grantId = ctx.oidc.session?.grantIdFor(ctx.oidc.client?.clientId);
// Guard: if session or client is missing (e.g. stale cookies after
// account deletion), bail out early so oidc-provider doesn't crash
// calling getOIDCScopeEncountered() on an undefined grant (#452).
if (!ctx.oidc.session || !ctx.oidc.client) {
return undefined;
}

// Check if there's an existing grant for this client/account pair.
// Optional chain: grantIdFor may be absent on a stale session stub.
const grantId = ctx.oidc.session.grantIdFor?.(ctx.oidc.client.clientId);

if (grantId) {
const existingGrant = await ctx.oidc.provider.Grant.find(grantId);
Expand All @@ -275,7 +284,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 on lines 267 to +287
Comment on lines 277 to +287
const grant = new ctx.oidc.provider.Grant({
accountId: ctx.oidc.session.accountId,
clientId: ctx.oidc.client.clientId,
Expand Down Expand Up @@ -393,6 +402,27 @@ export async function createProvider(issuer) {

// Render errors
renderError: async (ctx, out, error) => {
// Stale session recovery (#452): when oidc-provider crashes because
// a deleted account's session/grant is still in the browser cookies,
// expire those cookies and redirect back to the same URL. The retry
// 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 on lines +408 to +422
return;
}
Comment on lines +410 to +424
Comment on lines +410 to +424

Comment on lines +408 to +425
ctx.type = 'html';
ctx.body = `
<!DOCTYPE html>
Expand Down