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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user