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

@@ -42,6 +42,11 @@ ARG NEXT_PUBLIC_UMAMI_SRC=""
ARG NEXT_PUBLIC_UMAMI_ID="" ARG NEXT_PUBLIC_UMAMI_ID=""
ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC
ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID
# Shared session cookie across www.* and app.*. Needed at build time too: 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.
ARG COOKIE_DOMAIN=""
ENV COOKIE_DOMAIN=$COOKIE_DOMAIN
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build

View File

@@ -0,0 +1,232 @@
# Plan: Dashboard auf app.qrmaster.net
Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf `app.qrmaster.net`, Marketing/SEO bleibt auf `www.qrmaster.net`.
## Zielarchitektur
**Ein Docker-Image, ein Container, zwei Hostnames.** Caddy routet `www.qrmaster.net` und
`app.qrmaster.net` auf denselben Upstream. Die Middleware macht Host-basiertes Routing.
**Wichtig: keine Datei zieht um.** Die Next-App serviert auf beiden Hosts weiterhin alle Routen.
`src/middleware.ts` entscheidet pro Host, welcher Pfad ausgeliefert wird, und 301t den Rest auf den
jeweils anderen Host. Damit bleiben alle relativen Links (`router.push('/dashboard')`,
`<Link href="/settings">`) unverändert korrekt, weil sie innerhalb desselben Hosts aufgelöst werden.
```
qrmaster.net --301--> www.qrmaster.net (bleibt wie heute)
www.qrmaster.net -> Marketing, /login, /signup, /r/*, /api/*
app.qrmaster.net -> /dashboard /create /analytics /settings /bulk-creation
/integrations /qr/* /upgrade /onboarding, /api/*
```
## Fixierte Entscheidungen
| Frage | Entscheidung | Begründung |
|---|---|---|
| Hosting | Docker + Caddy auf eigenem Server | Bestand |
| `/login`, `/signup` | **bleiben auf www** | Alle 82 Marketing-CTAs zeigen auf `/signup`, `/signup` hat ein hartes Canonical auf www und trägt Ad-Traffic. Umzug wäre teuer ohne Nutzen. |
| `/onboarding` | zieht auf app | Reiner Logged-in-Flow, kein SEO-Wert |
| Host-Wechsel | genau **einmal**, nach erfolgreichem Login/Signup | einzige Cross-Host-Stelle im ganzen Flow |
| DB | keine Änderung | — |
## Teil A — Deine Aufgaben (Timo)
Reihenfolge beachten: A1A2 **vor** dem Deploy von Schritt B5, sonst zeigt die Subdomain ins Leere.
### A1. DNS
CNAME `app` → auf denselben Zielhost wie `www` (bzw. A-Record auf dieselbe Server-IP).
Kein Proxy-Only-Sonderfall nötig, Caddy holt das Cert selbst.
### A2. Caddyfile auf dem Server
`app.qrmaster.net` in den bestehenden Site-Block aufnehmen, damit Caddy automatisch ein
Let's-Encrypt-Cert zieht:
```caddyfile
www.qrmaster.net, app.qrmaster.net {
reverse_proxy qrmaster-web:3000
}
```
Danach `caddy reload`. Prüfen: `curl -sI https://app.qrmaster.net` muss 200 oder 301 liefern,
kein TLS-Fehler.
### A3. `.env` auf dem Server ergänzen
Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus:
```dotenv
NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
NEXT_PUBLIC_APP_URL=https://app.qrmaster.net
COOKIE_DOMAIN=.qrmaster.net
```
`NEXTAUTH_URL` bleibt `https://www.qrmaster.net` (wird nur noch von
`api/social-assets/route.ts` gelesen, kein Auth-Bezug mehr).
### A4. Google Cloud Console
Bei den OAuth-Credentials als **Authorized redirect URI** zusätzlich eintragen:
```
https://app.qrmaster.net/api/auth/google
```
Die alte www-URI **nicht löschen** sie wird während der Übergangszeit noch von
Sessions genutzt, die den Flow auf www gestartet haben.
### A5. Nichts zu tun bei Stripe und TikTok
- Stripe-Webhook zeigt auf `www.qrmaster.net/api/stripe/webhook` und bleibt gültig
(`/api/*` wird auf beiden Hosts weiter bedient, siehe B5).
- TikTok `redirect_uri` bleibt auf `qrmaster.net` verifizierte Domain, nicht anfassen.
### A6. Deploy
`npm run docker:prod` (Rebuild ist zwingend `NEXT_PUBLIC_*` wird zur Build-Zeit ins
Client-Bundle inlined, ein reiner Container-Restart genügt **nicht**).
## Teil B — Meine Aufgaben (Code), in Diff-Reihenfolge
### B1. Cookie-Domain teilen — muss zuerst live sein
Ohne das ist auf `app.qrmaster.net` jeder ausgeloggt: das `userId`-Cookie ist heute host-only.
- `src/lib/cookieConfig.ts:11``getAuthCookieOptions()`: `domain: process.env.COOKIE_DOMAIN` in Prod, in Dev `undefined` (localhost verträgt keine Punkt-Domain)
- `src/lib/cookieConfig.ts:24``getCsrfCookieOptions()`: dito
- `src/middleware.ts:34` — Attribution-Cookie: dito
- `src/app/(main)/api/auth/logout/route.ts:7`**kritisch**: löscht heute host-only. Nach der
Umstellung existieren bei Bestandsnutzern beide Varianten (alt host-only + neu domain-scoped).
Logout muss **beide** überschreiben, sonst bleibt ein Zombie-Cookie und der Nutzer ist nicht
wirklich ausgeloggt. Gilt für `userId`, `newsletter-admin` und das Attribution-Cookie.
- `src/app/(main)/api/auth/google/route.ts:53,62` — OAuth-State + Post-Auth-Redirect-Cookie
Kein Forced-Logout nötig: beide Cookie-Varianten tragen denselben signierten Wert, der Server
akzeptiert jede. `verifySignedUserIdEdge` prüft die Signatur, das Teilen über eigene Subdomains
ist unkritisch.
**Dieser Schritt kann allein auf www deployt werden, bevor die Subdomain existiert** — nach außen
unsichtbar, und wenn app.* dann live geht, funktionieren Sessions sofort.
### B2. `NEXT_PUBLIC_APP_URL` entflechten
Die Variable bedient heute App- **und** öffentliche URLs. Jede Fundstelle einzeln zuordnen:
**Muss auf `WWW_URL` (öffentlich, teils in QR-Codes kodiert):**
- `src/components/dashboard/QRCodeCard.tsx:82`**höchstes Risiko im ganzen Umbau**: Basis für
die in den QR-Code kodierte `/r/<slug>`-URL. Bleibt das auf `APP_URL`, zeigen alle neu
heruntergeladenen und gedruckten Codes auf die Subdomain.
- `src/app/(main)/r/[slug]/route.ts:50,61,84,89` — Landing-Basis vcard/text/coupon/feedback
- `src/lib/email.ts:56,562`, `src/lib/marketingEmail.ts:30` — Mail-Links auf Marketing-Inhalte
- `src/app/(main)/api/auth/signup/route.ts:20` — Verify-Mail-Link
- `src/app/(main)/api/stripe/checkout/route.ts:64``cancel_url``/pricing`
- `src/lib/metaConversions.ts:44`, `src/app/(main)/api/auth/signup/route.ts:150` — Event-Source-URLs
**Bleibt/wird `APP_URL` (eingeloggt):**
- `src/app/(main)/api/stripe/checkout/route.ts:63``success_url``/dashboard`
- `src/app/(main)/api/stripe/create-checkout-session/route.ts:112,128``appUrl` + returnPath
- `src/app/(main)/api/stripe/portal/route.ts:59``return_url``/settings`
- `src/lib/email.ts:505` — hartcodiertes `https://www.qrmaster.net/dashboard` im Mail-Footer
- `src/app/(main)/api/auth/google/route.ts:40,97``redirect_uri` (deckt A4 ab)
### B3. Post-Auth-Sprung auf app.*
Die einzige Cross-Host-Stelle. `sanitizeRedirectPath` (`src/lib/auth-flow.ts:4`) erlaubt bewusst
nur relative Pfade — bleibt so, ich baue den Host separat davor:
- `src/app/(main)/(auth)/login/ClientPage.tsx:56` und `login/LoginClient.tsx:65`
- `src/app/(main)/(auth)/signup/ClientPage.tsx:70`
- `src/app/(main)/api/auth/google/route.ts:224,228` — Server-Redirect
- `src/app/(main)/api/auth/verify-email/route.ts:38` — setzt Cookie und redirected
- `src/lib/auth-flow.ts:46``getPostOnboardingDestination`
Muster: relativen Zielpfad wie heute bestimmen, dann `new URL(path, APP_URL)`. Weil das
Auth-Cookie nach B1 auf `.qrmaster.net` gilt, ist der Nutzer nach dem Sprung sofort eingeloggt —
kein Token-Handover über die URL nötig.
### B4. Onboarding-Checkliste
`src/components/dashboard/OnboardingChecklist.tsx:141` verlinkt `/onboarding` mit
`redirect=/dashboard`. Beide Pfade liegen nach dem Umzug auf app.* → bleibt relativ, keine
Änderung. Nur verifizieren.
### B5. Middleware: Host-Routing
`src/middleware.ts` — Kern des Umbaus. Der bestehende Apex-Redirect (Zeile 49) bleibt unberührt.
Neu, direkt danach:
- Host `app.qrmaster.net`:
- `/api/*`, `/_next/*`, statische Dateien: durchlassen (Stripe-Webhook, CSRF, alles)
- `protectedPaths` (Zeile 145) + `/upgrade` + `/onboarding`: bedienen wie heute
- alles andere: 301 auf `WWW_URL` + gleicher Pfad
- `/r/*`: 301 auf www — QR-Redirects gehören nicht auf die App-Subdomain
- Host `www.qrmaster.net`:
- `protectedPaths` + `/upgrade` + `/onboarding`: 301 auf `APP_URL` + Pfad + Query
(damit alte Bookmarks und der Mail-Footer-Link weiter funktionieren)
- Auth-Fail-Redirect (Zeile 166): zeigt auf `/signup` — das liegt auf www, also absolut
auf `WWW_URL` umstellen, `redirect`-Param bleibt relativ
`/login` und `/signup` bleiben in `publicPaths` und werden nur auf www bedient.
### B6. Indexierung der Subdomain dichtmachen
`app.*` darf nicht in den Index, sonst Duplicate Content.
- `src/middleware.ts`: auf Host `app.*` `X-Robots-Tag: noindex, nofollow` auf alle Responses
- `public/robots-app.txt` neu anlegen (`User-agent: * / Disallow: /`), Middleware rewritet
`/robots.txt` auf app.* dorthin. `src/app/robots.ts` bleibt für www unverändert.
- `/sitemap.xml` auf app.* → 301 auf www
Gute Nachricht: `/dashboard`, `/create`, `/settings` sind in `src/app/robots.ts:7` bereits
disallowed und nicht in der Sitemap → **kein Ranking-Verlust durch den Umzug.** Die Canonicals
sind ohnehin hart auf www verdrahtet (`src/app/(main)/layout.tsx:13`).
### B7. Docker-Env-Kette
`NEXT_PUBLIC_*` wird zur Build-Zeit inlined **und** zur Laufzeit serverseitig gelesen. Beide
Stellen müssen übereinstimmen, sonst gibt es Bugs, die nur im Client oder nur im Server auftreten:
- `Dockerfile:34``NEXT_PUBLIC_APP_URL` auf `https://app.qrmaster.net`, neu
`ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"`
- `docker-compose.yml:58``NEXT_PUBLIC_WWW_URL` und `COOKIE_DOMAIN` ins `environment` des
`web`-Service durchreichen
- `env.example` + `.env.example` — neue Variablen dokumentieren
- `src/lib/env.ts` — optional, das Schema kennt `NEXT_PUBLIC_*` bisher gar nicht
## Deploy-Choreografie
Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko:
1. **Deploy 1 (nur B1):** Cookie-Domain auf `.qrmaster.net`. Nur www ist live, nach außen
unsichtbar. 24 h beobachten: Login, Logout, Checkout müssen normal laufen.
2. **A1 + A2 + A4:** DNS, Caddy, Google Console. `app.qrmaster.net` antwortet, serviert aber
noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht
indexierbar.
3. **Deploy 2 (B2B7):** Host-Routing scharf. Ab hier springt Login auf app.*.
Rollback: Deploy 2 zurücknehmen. Weil das Cookie auf `.qrmaster.net` gilt, bleiben Sessions
auch nach dem Rollback auf www gültig — niemand wird ausgeloggt. DNS/Caddy können stehen bleiben.
## Testcheckliste (nach Deploy 2)
Jeweils über beide Hosts:
- [ ] `www.qrmaster.net/dashboard` → 301 auf `app.qrmaster.net/dashboard`, eingeloggt
- [ ] `app.qrmaster.net/pricing` → 301 auf www
- [ ] Signup auf www → Verify-Mail → Link führt eingeloggt auf app.*
- [ ] Google-Login von www aus → landet eingeloggt auf app.*/dashboard bzw. /onboarding
- [ ] Logout auf app.* → auf www **auch** ausgeloggt (prüft B1, häufigster Fehler)
- [ ] Checkout: Upgrade auf app.* → Stripe → `success_url` app.*/dashboard, Abbruch → www/pricing
- [ ] Stripe-Portal → zurück auf app.*/settings
- [ ] Stripe-Webhook feuert weiter (Dashboard → Events, keine 4xx)
- [ ] **QR-Code neu anlegen + herunterladen → kodierte URL ist `www.qrmaster.net/r/<slug>`**,
nicht app.* (prüft B2, das teuerste Fehlerbild)
- [ ] Bestehender `/r/<slug>` redirected + trackt weiter, vcard/coupon/feedback-Landings laden
- [ ] Mutation auf app.* (QR umbenennen) → CSRF greift, kein 403
- [ ] `curl -sI https://app.qrmaster.net/dashboard | grep -i x-robots-tag` → noindex
- [ ] `https://app.qrmaster.net/robots.txt``Disallow: /`
- [ ] Search Console: `app.qrmaster.net` **nicht** als Property anlegen, keine Sitemap einreichen
## Risiken
| Risiko | Wo | Absicherung |
|---|---|---|
| Gedruckte QR-Codes zeigen auf app.* | `QRCodeCard.tsx:82` | B2, explizit im Test |
| Logout wirkt nicht (Zombie-Cookie) | `logout/route.ts` | B1 löscht beide Varianten |
| Client/Server-Env divergieren | `Dockerfile` vs. `docker-compose.yml` | B7, beide Stellen setzen |
| Google-OAuth bricht | Cloud Console | A4, alte URI stehen lassen |
| Duplicate Content auf app.* | — | B6 vor Deploy 2 |
## Aufwand
- Deine Seite: ~45 min (DNS, Caddy, .env, Google Console, Deploy)
- Meine Seite: ~46 h Code über zwei Deploys
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust

View File

@@ -46,6 +46,7 @@ services:
args: args:
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
container_name: qrmaster-web container_name: qrmaster-web
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -56,6 +57,7 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}

View File

@@ -16,6 +16,11 @@ DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
NEXTAUTH_URL=http://localhost:3050 NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=your-secret-key-here-change-in-production NEXTAUTH_SECRET=your-secret-key-here-change-in-production
# Session cookie scope. Leave EMPTY for local development (browsers reject dotted
# domains on localhost). In production set to `.qrmaster.net` so the session is shared
# between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production.
COOKIE_DOMAIN=
# OAuth Providers (Optional) # OAuth Providers (Optional)
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=

View File

@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import {
appendExpiredCookies,
getAuthCookieOptions,
getCookieDomain,
getFlowCookieOptions,
} from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session'; import { signUserId } from '@/lib/session';
import { import {
appendRedirectParam, appendRedirectParam,
@@ -16,8 +21,6 @@ import {
} from '@/lib/revops'; } from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server'; import { triggerLifecycleScoring } from '@/lib/revops-server';
const isProduction = process.env.NODE_ENV === 'production';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const code = searchParams.get('code'); const code = searchParams.get('code');
@@ -50,24 +53,16 @@ export async function GET(request: NextRequest) {
googleAuthUrl.searchParams.set('state', oauthState); googleAuthUrl.searchParams.set('state', oauthState);
const response = NextResponse.redirect(googleAuthUrl); const response = NextResponse.redirect(googleAuthUrl);
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, { response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, getFlowCookieOptions(60 * 10));
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
if (redirectTarget) { if (redirectTarget) {
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, { response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, getFlowCookieOptions(60 * 10));
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
} else { } else {
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); response.cookies.delete({
name: POST_AUTH_REDIRECT_COOKIE_NAME,
path: '/',
domain: getCookieDomain(),
});
} }
return response; return response;
@@ -229,17 +224,20 @@ export async function GET(request: NextRequest) {
const response = NextResponse.redirect(redirectUrl.toString()); const response = NextResponse.redirect(redirectUrl.toString());
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
response.cookies.delete(ATTRIBUTION_COOKIE_NAME); // 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; return response;
} catch (error) { } catch (error) {
console.error('Google OAuth error:', error); console.error('Google OAuth error:', error);
const errorResponse = NextResponse.redirect( const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed` `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
); );
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
return errorResponse; return errorResponse;
} }
} }

View File

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

View File

@@ -1,39 +1,138 @@
/** /**
* Cookie configuration helpers * Cookie configuration helpers
* Automatically uses secure settings in production * Automatically uses secure settings in production
*/ */
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
/** /**
* Get cookie options for authentication cookies * Domain the session cookies are scoped to.
*/ *
export function getAuthCookieOptions() { * Set `COOKIE_DOMAIN=.qrmaster.net` in production so one session is shared between
return { * www.qrmaster.net (marketing, login) and app.qrmaster.net (the app). Without it the
httpOnly: true, * cookie stays host-only and a user logged in on www would be anonymous on app.
secure: isProduction, // HTTPS only in production *
sameSite: 'lax' as const, * Only honoured in production on purpose: browsers reject dotted domains for
maxAge: 60 * 60 * 24 * 7, // 7 days * `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) {
* Get cookie options for CSRF tokens return undefined;
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern }
*/
export function getCsrfCookieOptions() { const domain = process.env.COOKIE_DOMAIN?.trim();
return {
httpOnly: false, // Client needs to read this token for the header return domain ? domain : undefined;
secure: isProduction, // HTTPS only in production }
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24, // 24 hours /**
path: '/', // Available on all paths * Get cookie options for authentication cookies
}; */
} export function getAuthCookieOptions() {
return {
/** httpOnly: true,
* Check if running in production secure: isProduction, // HTTPS only in production
*/ sameSite: 'lax' as const,
export function isProductionEnvironment(): boolean { path: '/', // Explicit so the expiry in buildExpiredCookieHeaders() matches
return isProduction; 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, serializeAttributionCookie,
} from '@/lib/revops'; } from '@/lib/revops';
import { verifySignedUserIdEdge } from '@/lib/session-edge'; import { verifySignedUserIdEdge } from '@/lib/session-edge';
import { getCookieDomain } from '@/lib/cookieConfig';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@@ -37,6 +38,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: 60 * 60 * 24 * 90, maxAge: 60 * 60 * 24 * 90,
domain: getCookieDomain(),
}); });
return response; return response;