Files
QR-master/src/lib/cookieConfig.ts
Timo Knuth 113acc073f Make the session cookie name configurable for a staging deployment
Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real
qrmaster.net subdomain.

Production scopes its session cookie to .qrmaster.net, so the browser sends it to every
subdomain including staging. With both environments naming the cookie `userId`, the
browser holds two cookies of the same name and cookies.get() picks one arbitrarily -
staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick
`userId_test` instead. Production keeps the `userId` default; changing it there would
invalidate every existing session.

Wired getAuthCookieName() into the six places that named the cookie literally. The account
deletion route now expires both the host-only and the domain-scoped variant like the logout
route already does, instead of a single cookies().delete() that would leave the other one
behind.

NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be
built pointing at the staging host - the defaults keep a plain production build byte
identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env
is inlined into the Edge middleware bundle.

robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise
returns the production robots.txt and invites crawlers to index a duplicate of www.

docker-compose.test.yml is the staging overlay. Two things it must get right, both verified
against `docker compose config`:

- db and redis need `networks: !override`. Compose MERGES the networks mapping from the base
  file, and since qrmaster-network is external and shared, a plain list left them attached
  to it - `db` would then resolve to two containers and staging could read and write the
  production database.
- The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations
  stopped in April 2026 and the schema has moved on through manual SQL since, so applying
  them to a fresh database would build a stale schema. Staging gets its schema from
  `pg_dump --schema-only` against production instead.

Verified: tsc clean, production build succeeds, and the merged compose config confirms
staging keeps db/redis off the shared network while production resolves unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:09:56 +02:00

154 lines
4.5 KiB
TypeScript

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