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:
2026-08-12 19:01:14 +02:00
parent e3276f5943
commit 35ea8cc3e9
8 changed files with 423 additions and 92 deletions

View File

@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import {
appendExpiredCookies,
getAuthCookieOptions,
getCookieDomain,
getFlowCookieOptions,
} from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import {
appendRedirectParam,
@@ -16,8 +21,6 @@ import {
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
const isProduction = process.env.NODE_ENV === 'production';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
@@ -50,24 +53,16 @@ export async function GET(request: NextRequest) {
googleAuthUrl.searchParams.set('state', oauthState);
const response = NextResponse.redirect(googleAuthUrl);
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, getFlowCookieOptions(60 * 10));
if (redirectTarget) {
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, getFlowCookieOptions(60 * 10));
} else {
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
response.cookies.delete({
name: POST_AUTH_REDIRECT_COOKIE_NAME,
path: '/',
domain: getCookieDomain(),
});
}
return response;
@@ -229,17 +224,20 @@ export async function GET(request: NextRequest) {
const response = NextResponse.redirect(redirectUrl.toString());
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
// Must stay after the last cookies.set()/delete() call - see appendExpiredCookies.
// The attribution cookie lives 90 days, so a pre-COOKIE_DOMAIN host-only copy can
// still be around and has to be expired alongside the domain-scoped one.
appendExpiredCookies(response.headers, [{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false }]);
return response;
} catch (error) {
console.error('Google OAuth error:', error);
const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
return errorResponse;
}
}

View File

@@ -1,30 +1,18 @@
import { NextResponse } from 'next/server';
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set('userId', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
response.cookies.set('newsletter-admin', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
return response;
}
import { NextResponse } from 'next/server';
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
import { appendExpiredCookies } from '@/lib/cookieConfig';
export async function POST() {
const response = NextResponse.json({ success: true });
// Deliberately not using response.cookies.set() here: it is keyed by cookie name, so
// it can only ever emit one variant per cookie. Logout has to expire both the
// host-only and the domain-scoped variant (see appendExpiredCookies).
appendExpiredCookies(response.headers, [
{ name: 'userId', httpOnly: true },
{ name: 'newsletter-admin', httpOnly: true },
{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
]);
return response;
}

View File

@@ -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;
}

View File

@@ -6,6 +6,7 @@ import {
serializeAttributionCookie,
} from '@/lib/revops';
import { verifySignedUserIdEdge } from '@/lib/session-edge';
import { getCookieDomain } from '@/lib/cookieConfig';
const isProduction = process.env.NODE_ENV === 'production';
@@ -37,6 +38,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 90,
domain: getCookieDomain(),
});
return response;