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