/** * Cookie configuration helpers * Automatically uses secure settings in production */ const isProduction = process.env.NODE_ENV === 'production'; /** * Domain the session cookies are scoped to. * * Set `COOKIE_DOMAIN=.qrmaster.net` in production so one session is shared between * www.qrmaster.net (marketing, login) and app.qrmaster.net (the app). Without it the * cookie stays host-only and a user logged in on www would be anonymous on app. * * Only honoured in production on purpose: browsers reject dotted domains for * `localhost`, so a prod .env copied into a dev environment would silently break * every login instead of just ignoring the value. */ export function getCookieDomain(): string | undefined { if (!isProduction) { return undefined; } const domain = process.env.COOKIE_DOMAIN?.trim(); return domain ? domain : undefined; } /** * Name of the session cookie. * * Configurable so a staging deployment on another qrmaster.net subdomain can pick a * distinct name. Production scopes its cookie to `.qrmaster.net`, so the browser sends it * to testmodul.qrmaster.net as well; two cookies with the same name would make * `cookies.get()` ambiguous and staging logins flaky. * * Like COOKIE_DOMAIN this must be set at build time too, because process.env is inlined * into the Edge middleware bundle. */ export function getAuthCookieName(): string { return process.env.AUTH_COOKIE_NAME?.trim() || 'userId'; } /** * Get cookie options for authentication cookies */ export function getAuthCookieOptions() { return { httpOnly: true, secure: isProduction, // HTTPS only in production sameSite: 'lax' as const, path: '/', // Explicit so the expiry in buildExpiredCookieHeaders() matches maxAge: 60 * 60 * 24 * 7, // 7 days domain: getCookieDomain(), }; } /** * Get cookie options for CSRF tokens * Note: httpOnly is false so the client can read it, but we verify via double-submit pattern */ export function getCsrfCookieOptions() { return { httpOnly: false, // Client needs to read this token for the header secure: isProduction, // HTTPS only in production sameSite: 'lax' as const, maxAge: 60 * 60 * 24, // 24 hours path: '/', // Available on all paths domain: getCookieDomain(), }; } /** * Get cookie options for short-lived flow cookies (OAuth state, post-auth redirect). */ export function getFlowCookieOptions(maxAgeSeconds: number) { return { httpOnly: true, secure: isProduction, sameSite: 'lax' as const, path: '/', maxAge: maxAgeSeconds, domain: getCookieDomain(), }; } function serializeExpiredCookie(name: string, httpOnly: boolean, domain?: string): string { const parts = [ `${name}=`, 'Path=/', 'Max-Age=0', 'Expires=Thu, 01 Jan 1970 00:00:00 GMT', 'SameSite=Lax', ]; if (domain) { parts.push(`Domain=${domain}`); } if (httpOnly) { parts.push('HttpOnly'); } if (isProduction) { parts.push('Secure'); } return parts.join('; '); } /** * Build every `Set-Cookie` value needed to actually delete a cookie. * * A cookie is only removed by a Set-Cookie whose name, path AND domain match what the * browser stored. Since we moved the session to a shared COOKIE_DOMAIN, a returning user * can hold BOTH variants at once: a host-only cookie set before the switch and a * domain-scoped one set after. Expiring only one leaves the other in place and the user * stays effectively logged in — so we always emit both. */ export function buildExpiredCookieHeaders(name: string, httpOnly: boolean): string[] { const domain = getCookieDomain(); const headers = [serializeExpiredCookie(name, httpOnly)]; if (domain) { headers.push(serializeExpiredCookie(name, httpOnly, domain)); } return headers; } /** * Append expiry headers for the given cookies onto a response. * * IMPORTANT: call this AFTER the last `response.cookies.set()` on the same response. * Next's ResponseCookies is keyed by cookie name and rewrites the whole `set-cookie` * header from its internal map on every `set()`, which would drop these appends and * collapse our two variants back into one. */ export function appendExpiredCookies( headers: Headers, cookies: Array<{ name: string; httpOnly: boolean }> ): void { for (const cookie of cookies) { for (const value of buildExpiredCookieHeaders(cookie.name, cookie.httpOnly)) { headers.append('set-cookie', value); } } } /** * Check if running in production */ export function isProductionEnvironment(): boolean { return isProduction; }