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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
232
PLAN_APP_SUBDOMAIN_2026-08-12.md
Normal file
232
PLAN_APP_SUBDOMAIN_2026-08-12.md
Normal 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: 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/<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 (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/<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: ~4–6 h Code über zwei Deploys
|
||||||
|
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust
|
||||||
@@ -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:-}
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user