Share session cookies across www and app subdomains
Groundwork for moving the app to app.qrmaster.net: the session has to survive the host change from www.qrmaster.net to app.qrmaster.net. - Add COOKIE_DOMAIN and apply it to the auth, CSRF, attribution and OAuth flow cookies. Honoured only in production, because browsers reject dotted domains on localhost - a prod .env copied into a dev environment would otherwise break every login instead of just ignoring the value. - Expire both the host-only and the domain-scoped variant on logout. Next's ResponseCookies is keyed by cookie name and rewrites the entire set-cookie header from its internal map on every set(), so the two variants must be appended manually - otherwise one overwrites the other and the surviving stale cookie keeps the user signed in. - Pass COOKIE_DOMAIN as both build arg and runtime env: process.env is inlined into the Edge middleware bundle, so a runtime-only value would leave the middleware and the route handlers disagreeing about the cookie scope. No behaviour change while COOKIE_DOMAIN is unset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,138 @@
|
||||
/**
|
||||
* Cookie configuration helpers
|
||||
* Automatically uses secure settings in production
|
||||
*/
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Get cookie options for authentication cookies
|
||||
*/
|
||||
export function getAuthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
return isProduction;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user