import crypto from 'node:crypto'; import { COOKIE_NAMES, generateToken, getLegacyCsrfCookieOptions, getLegacySidCookieOptions, getSidCookieOptions } from '../utils/cookie-options.js'; function signSid(sessionSecret, sid) { return crypto .createHmac('sha256', sessionSecret) .update(sid) .digest('base64url'); } function parseAndVerifySignedSid(sessionSecret, signedValue) { if (!signedValue || typeof signedValue !== 'string') return null; const idx = signedValue.lastIndexOf('.'); if (idx <= 0) return null; const sid = signedValue.slice(0, idx); const sig = signedValue.slice(idx + 1); if (!sid || !sig) return null; const expected = signSid(sessionSecret, sid); try { if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return null; } } catch { return null; } return sid; } function getCookieValuesFromHeader(cookieHeader, name) { if (!cookieHeader || typeof cookieHeader !== 'string') return []; // Multiple cookies with the same name can exist if older cookies were scoped // to a different path (e.g. '/api') than newer ones ('/'). const values = []; const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g'); let match; while ((match = pattern.exec(cookieHeader)) !== null) { values.push(match[1]); } return values; } export function sidMiddleware({ sessionSecret, config }) { if (!sessionSecret) { throw new Error('sidMiddleware requires sessionSecret'); } return function sid(req, res, next) { // If older cookies (scoped to '/api') exist alongside newer cookies // (scoped to '/'), browsers may send both. Some parsers will then pick the // "wrong" one depending on header order, causing auth to appear connected // in one request and missing in another. const rawCookieHeader = req.headers?.cookie || ''; if (rawCookieHeader.includes(`${COOKIE_NAMES.sid}=`)) { res.clearCookie(COOKIE_NAMES.sid, getLegacySidCookieOptions(config)); } if (rawCookieHeader.includes(`${COOKIE_NAMES.csrf}=`)) { res.clearCookie(COOKIE_NAMES.csrf, getLegacyCsrfCookieOptions(config)); } const signedCandidates = getCookieValuesFromHeader( rawCookieHeader, COOKIE_NAMES.sid ); const signedFromParser = req.cookies?.[COOKIE_NAMES.sid]; if (signedFromParser) signedCandidates.push(signedFromParser); // If multiple signed SIDs are present (legacy '/api' cookie + current '/'), // browsers tend to send the more-specific path cookie first. // Prefer the last valid SID to bias towards the newer '/' cookie. let sid = null; for (let i = signedCandidates.length - 1; i >= 0; i -= 1) { const signed = signedCandidates[i]; sid = parseAndVerifySignedSid(sessionSecret, signed); if (sid) break; } if (!sid) { sid = generateToken(24); const signedSid = `${sid}.${signSid(sessionSecret, sid)}`; res.cookie(COOKIE_NAMES.sid, signedSid, getSidCookieOptions(config)); } req.sid = sid; next(); }; }