From 35ea8cc3e91248feb3ce8a159d422ca5592eb706 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 12 Aug 2026 19:01:14 +0200 Subject: [PATCH] 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 --- Dockerfile | 5 + PLAN_APP_SUBDOMAIN_2026-08-12.md | 232 ++++++++++++++++++++++++ docker-compose.yml | 2 + env.example | 5 + src/app/(main)/api/auth/google/route.ts | 44 +++-- src/app/(main)/api/auth/logout/route.ts | 48 ++--- src/lib/cookieConfig.ts | 177 ++++++++++++++---- src/middleware.ts | 2 + 8 files changed, 423 insertions(+), 92 deletions(-) create mode 100644 PLAN_APP_SUBDOMAIN_2026-08-12.md diff --git a/Dockerfile b/Dockerfile index 9724fa5..2c7b039 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,11 @@ ARG NEXT_PUBLIC_UMAMI_SRC="" ARG NEXT_PUBLIC_UMAMI_ID="" ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC 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 npm run build diff --git a/PLAN_APP_SUBDOMAIN_2026-08-12.md b/PLAN_APP_SUBDOMAIN_2026-08-12.md new file mode 100644 index 0000000..7ef55f7 --- /dev/null +++ b/PLAN_APP_SUBDOMAIN_2026-08-12.md @@ -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')`, +``) 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: A1–A2 **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/`-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 (B2–B7):** 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/`**, + nicht app.* (prüft B2, das teuerste Fehlerbild) +- [ ] Bestehender `/r/` 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: ~4–6 h Code über zwei Deploys +- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust diff --git a/docker-compose.yml b/docker-compose.yml index 44b2ef5..be5fdd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: args: NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} container_name: qrmaster-web restart: unless-stopped environment: @@ -56,6 +57,7 @@ services: NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} diff --git a/env.example b/env.example index 412e041..ccaf8d3 100644 --- a/env.example +++ b/env.example @@ -16,6 +16,11 @@ DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public NEXTAUTH_URL=http://localhost:3050 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) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts index ca5189b..bbd8d92 100644 --- a/src/app/(main)/api/auth/google/route.ts +++ b/src/app/(main)/api/auth/google/route.ts @@ -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; } } diff --git a/src/app/(main)/api/auth/logout/route.ts b/src/app/(main)/api/auth/logout/route.ts index d621764..4aabd9d 100644 --- a/src/app/(main)/api/auth/logout/route.ts +++ b/src/app/(main)/api/auth/logout/route.ts @@ -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; +} diff --git a/src/lib/cookieConfig.ts b/src/lib/cookieConfig.ts index e868075..e5794a8 100644 --- a/src/lib/cookieConfig.ts +++ b/src/lib/cookieConfig.ts @@ -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; +} diff --git a/src/middleware.ts b/src/middleware.ts index d30bbce..76001eb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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;