From 8fa28299578b99b0201e9c2bbcc782263434b1ab Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Mon, 29 Jun 2026 22:23:55 +0300 Subject: [PATCH 1/8] fix: auth callback redirect, onboarding enforcement, and session validation Production sign-in skipped onboarding because the Cloudflare x-forwarded-host rewrite overwrote the redirect path. Enforce handle setup in the proxy, block open redirects, and verify sessions with getUser() on the server. Co-authored-by: Cursor --- src/app/(auth)/login/page.tsx | 3 +- src/app/auth/callback/route.ts | 60 +++++++++++-------------- src/app/leaderboard/page.tsx | 3 +- src/app/p/[id]/page.tsx | 3 +- src/app/p/[id]/prep/page.tsx | 3 +- src/app/page.tsx | 3 +- src/app/u/[handle]/page.tsx | 3 +- src/lib/safe-redirect.ts | 10 +++++ src/lib/supabase/client.ts | 7 ++- src/lib/supabase/env.ts | 16 +++++++ src/lib/supabase/middleware.ts | 82 +++++++++++++++++----------------- src/lib/supabase/server.ts | 36 +++++++-------- 12 files changed, 119 insertions(+), 110 deletions(-) create mode 100644 src/lib/safe-redirect.ts create mode 100644 src/lib/supabase/env.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index aea13cd..a137164 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useState } from "react"; import { useSearchParams } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; +import { safeRedirectPath } from "@/lib/safe-redirect"; function LoginForm() { const [email, setEmail] = useState(""); @@ -10,7 +11,7 @@ function LoginForm() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const searchParams = useSearchParams(); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirectPath(searchParams.get("redirect")); const authError = searchParams.get("error"); const supabase = createClient(); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 75213cc..137055e 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from "next/server"; import { createServerClient, parseCookieHeader } from "@supabase/ssr"; import type { CookieOptions } from "@supabase/ssr"; +import { safeRedirectPath } from "@/lib/safe-redirect"; +import { getSupabaseEnv } from "@/lib/supabase/env"; export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirectPath(searchParams.get("redirect")); const errorParam = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); @@ -25,38 +27,29 @@ export async function GET(request: Request) { options: CookieOptions; }[] = []; - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return parseCookieHeader(request.headers.get("Cookie") ?? "").map( - (cookie) => ({ name: cookie.name, value: cookie.value ?? "" }) - ); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - collectedCookies.push({ name, value, options }); - }); - }, + const { url, anonKey } = getSupabaseEnv(); + + const supabase = createServerClient(url, anonKey, { + cookies: { + getAll() { + return parseCookieHeader(request.headers.get("Cookie") ?? "").map( + (cookie) => ({ name: cookie.name, value: cookie.value ?? "" }) + ); }, - } - ); + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => { + collectedCookies.push({ name, value, options }); + }); + }, + }, + }); const { data, error } = await supabase.auth.exchangeCodeForSession(code); - console.log( - "[auth/callback] exchange result:", - error ? `error=${error.message}` : "ok", - `cookies collected: ${collectedCookies.length}`, - `cookie names: [${collectedCookies.map((c) => c.name).join(", ")}]` - ); - if (!error && data.session) { const user = data.session.user; - let redirectTo = `${origin}${redirect}`; + let redirectPath = redirect; if (user) { const { data: profile } = await supabase @@ -66,25 +59,22 @@ export async function GET(request: Request) { .maybeSingle(); if (!profile?.handle) { - redirectTo = `${origin}/onboarding`; + redirectPath = "/onboarding"; } } const forwardedHost = request.headers.get("x-forwarded-host"); - if (forwardedHost && process.env.NODE_ENV !== "development") { - redirectTo = `https://${forwardedHost}${redirect}`; - } + const host = + forwardedHost && process.env.NODE_ENV !== "development" + ? `https://${forwardedHost.split(",")[0].trim()}` + : origin; + const redirectTo = `${host}${redirectPath}`; const response = NextResponse.redirect(redirectTo); for (const { name, value, options } of collectedCookies) { response.cookies.set(name, value, options); } - console.log( - "[auth/callback] redirect to:", - redirectTo, - `Set-Cookie count: ${response.headers.getSetCookie().length}` - ); return response; } diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index c90a483..3374245 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -27,8 +27,7 @@ async function getProducts(): Promise { .order("created_at", { ascending: false }) .limit(20); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); let result = (products ?? []) as ProductWithCounts[]; diff --git a/src/app/p/[id]/page.tsx b/src/app/p/[id]/page.tsx index 271d8db..53ccf18 100644 --- a/src/app/p/[id]/page.tsx +++ b/src/app/p/[id]/page.tsx @@ -36,8 +36,7 @@ async function getProduct(id: string): Promise { .single(); const product = data as ProductWithCounts | null; - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); let userHasVoted = false; if (user) { diff --git a/src/app/p/[id]/prep/page.tsx b/src/app/p/[id]/prep/page.tsx index 787e7a9..4ec52ff 100644 --- a/src/app/p/[id]/prep/page.tsx +++ b/src/app/p/[id]/prep/page.tsx @@ -26,8 +26,7 @@ async function getProductForPrep(id: string): Promise<{ .eq("id", id) .single(); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); const isOwner = !!user && product?.builder_id === user.id; diff --git a/src/app/page.tsx b/src/app/page.tsx index 0a36c55..d832235 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,8 +24,7 @@ async function getProducts(sortMode: "hot" | "new"): Promise 0) { diff --git a/src/lib/safe-redirect.ts b/src/lib/safe-redirect.ts new file mode 100644 index 0000000..593210b --- /dev/null +++ b/src/lib/safe-redirect.ts @@ -0,0 +1,10 @@ +/** Allow only same-origin relative paths (blocks open redirects). */ +export function safeRedirectPath(raw: string | null | undefined): string { + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) { + return "/"; + } + if (raw.includes("://") || raw.includes("\\")) { + return "/"; + } + return raw; +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 9f2891b..fea9b60 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -1,8 +1,7 @@ import { createBrowserClient } from "@supabase/ssr"; +import { getSupabaseEnv } from "./env"; export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); + const { url, anonKey } = getSupabaseEnv(); + return createBrowserClient(url, anonKey); } diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts new file mode 100644 index 0000000..fc6befd --- /dev/null +++ b/src/lib/supabase/env.ts @@ -0,0 +1,16 @@ +import { isMockMode } from "@/lib/mock-data"; + +export function getSupabaseEnv() { + if (isMockMode()) { + return { + url: "https://placeholder.supabase.co", + anonKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBsYWNlaG9sZGVyIn0.placeholder", + }; + } + + return { + url: process.env.NEXT_PUBLIC_SUPABASE_URL!, + anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + }; +} diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index b71c76b..fd4f672 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -1,5 +1,8 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { getSupabaseEnv } from "./env"; + +const PUBLIC_PATHS = ["/login", "/onboarding", "/auth"]; export async function updateSession(request: NextRequest) { const pathname = request.nextUrl.pathname; @@ -9,56 +12,38 @@ export async function updateSession(request: NextRequest) { } let supabaseResponse = NextResponse.next({ request }); - let setAllCalled = false; - let setAllCookieNames: string[] = []; + const { url, anonKey } = getSupabaseEnv(); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet, headers) { - setAllCalled = true; - setAllCookieNames = cookiesToSet.map( - (c) => `${c.name}=${c.value ? "set" : "CLEAR(maxAge=0)"}` - ); - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ); - supabaseResponse = NextResponse.next({ request }); - cookiesToSet.forEach(({ name, value, options }) => - supabaseResponse.cookies.set(name, value, options) + const supabase = createServerClient(url, anonKey, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet, headers) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + if (headers) { + Object.entries(headers).forEach(([key, value]) => + supabaseResponse.headers.set(key, value as string) ); - if (headers) { - Object.entries(headers).forEach(([key, value]) => - supabaseResponse.headers.set(key, value as string) - ); - } - }, + } }, - } - ); - - const authCookieNames = request.cookies - .getAll() - .filter((c) => c.name.includes("auth-token")) - .map((c) => c.name); + }, + }); const { data: { user }, } = await supabase.auth.getUser(); - console.log( - `[proxy] ${pathname} | cookies: [${authCookieNames.join(", ")}] | user: ${user?.id ?? "none"} | setAll: ${setAllCalled ? `YES [${setAllCookieNames.join(", ")}]` : "no"}` - ); - const protectedRoutes = ["/submit", "/settings", "/admin"]; - const isProtected = protectedRoutes.some((route) => - pathname.startsWith(route) - ) || /^\/p\/[^/]+\/edit/.test(pathname); + const isProtected = + protectedRoutes.some((route) => pathname.startsWith(route)) || + /^\/p\/[^/]+\/edit/.test(pathname); if (isProtected && !user) { const url = request.nextUrl.clone(); @@ -67,5 +52,20 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url); } + const isPublicAuthPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); + if (user && !isPublicAuthPath) { + const { data: profile } = await supabase + .from("profiles") + .select("handle") + .eq("id", user.id) + .maybeSingle(); + + if (!profile?.handle) { + const url = request.nextUrl.clone(); + url.pathname = "/onboarding"; + return NextResponse.redirect(url); + } + } + return supabaseResponse; } diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index dac2490..b8d36fc 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -1,27 +1,25 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; +import { getSupabaseEnv } from "./env"; export async function createClient() { const cookieStore = await cookies(); + const { url, anonKey } = getSupabaseEnv(); - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // Called from a Server Component — ignore - } - }, + return createServerClient(url, anonKey, { + cookies: { + getAll() { + return cookieStore.getAll(); }, - } - ); + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // Called from a Server Component — ignore + } + }, + }, + }); } From 888ceb3765a55dedd86db51d82d73172cba0950f Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Tue, 30 Jun 2026 22:26:23 +0300 Subject: [PATCH 2/8] chore: save latest auth fixes and developer handoff for review Preserve proxy redirect cookie forwarding, onboarding profile upsert, and callback error logging. Add DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md for the next developer debugging auth on fix/auth. Co-authored-by: Cursor --- DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md | 230 ++++++++++++++++++++++++++ src/app/auth/callback/route.ts | 14 +- src/app/onboarding/actions.ts | 6 +- src/lib/supabase/middleware.ts | 20 ++- 4 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md diff --git a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md new file mode 100644 index 0000000..0a7a7b3 --- /dev/null +++ b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md @@ -0,0 +1,230 @@ +# Developer Handoff — Product Builders + +Prepared for review and debugging. Read this before diving into the codebase. + +--- + +## Project status + +| Item | Value | +|---|---| +| **Current branch** | `fix/auth` | +| **Latest commit** | _(see `git log -1 --oneline` after pull)_ | +| **GitHub remote** | `https://github.com/SaharPak/productbuilders-app.git` | +| **Open PR (auth work)** | https://github.com/SaharPak/productbuilders-app/pull/54 | +| **Related draft PR** | https://github.com/SaharPak/productbuilders-app/pull/53 (broader Supabase/RLS fixes, not merged) | +| **Production URL** | https://productbuilders.app | +| **Local changes pushed?** | Yes — branch `fix/auth` pushed to `origin` (verify with `git status -sb`) | + +--- + +## What changed recently + +### On branch `fix/auth` (commit `8fa2829` and follow-ups) + +- **Auth callback redirect fix** (`src/app/auth/callback/route.ts`): Production OAuth/magic-link callback no longer lets Cloudflare `x-forwarded-host` overwrite the onboarding redirect. New users should land on `/onboarding`, not skip it. +- **Open redirect protection** (`src/lib/safe-redirect.ts`): Login and callback only accept same-origin relative paths. +- **Onboarding enforcement in proxy** (`src/lib/supabase/middleware.ts`): Signed-in users without a profile handle are redirected to `/onboarding` on non-public routes. +- **Session validation** (several server pages): Replaced `getSession()` with `getUser()` so auth is verified server-side. +- **Centralized Supabase env** (`src/lib/supabase/env.ts`): Supports mock mode when env vars are absent (builds without secrets). + +### Uncommitted-at-inspection fixes (saved in latest commit on this branch) + +- **`redirectWithCookies` helper** in `src/lib/supabase/middleware.ts`: Forwards auth cookies set during `getUser()` when the proxy issues a redirect. Without this, session refresh cookies can be dropped on redirect and users may appear logged out. +- **Onboarding profile upsert** in `src/app/onboarding/actions.ts`: Changed from `.update()` to `.upsert()` so onboarding works even if the `handle_new_user` trigger did not create a profile row. +- **Callback error logging** in `src/app/auth/callback/route.ts`: Logs Supabase code-exchange failures with message and status before redirecting to `/login?error=auth`. + +--- + +## Current problem / suspected issue + +**Primary area of concern: authentication flow (sign-in, session persistence, onboarding).** + +The owner has been stuck on auth for 2–3 weeks. Symptoms reported in recent debugging sessions: + +1. **Sign-in appears to fail or loop** on production (Cloudflare/Vercel behind proxy). +2. **New users may skip onboarding** or get stuck without a handle. +3. **Session may not persist** after proxy redirects (login → protected route, or home → onboarding redirect). + +### Known root causes already addressed on `fix/auth` + +| Issue | Location | Fix | +|---|---|---| +| `x-forwarded-host` overwrote onboarding redirect | `src/app/auth/callback/route.ts` | Build redirect from `redirectPath` + host separately | +| Auth cookies lost on proxy redirect | `src/lib/supabase/middleware.ts` | `redirectWithCookies()` | +| Profile row missing at onboarding | `src/app/onboarding/actions.ts` | `.upsert()` instead of `.update()` | +| Client-trusted session on server pages | Multiple `src/app/**/page.tsx` | `getUser()` instead of `getSession()` | + +### Still unverified / may need production testing + +- Google OAuth and magic-link flows end-to-end on **production** (not just localhost). +- Supabase Auth redirect URLs include `https://productbuilders.app/auth/callback` (and localhost for dev). +- Whether `handle_new_user` trigger in DB is present and firing (see migrations). +- Whether PR #53 migrations (004–006) are applied in production Supabase (storage RLS, additional RLS fixes, week cycle). + +### Local checks run at handoff time + +| Command | Result | +|---|---| +| `npm run lint` | **Passed** | +| `npm run build` | **Passed** | +| `npm test` | Not defined in `package.json` | +| `npm run typecheck` | Not defined; TypeScript runs as part of `npm run build` and passed | + +No failing build or lint errors at handoff time. + +--- + +## How to run locally + +**Package manager:** npm (`package-lock.json` present) + +```bash +git clone https://github.com/SaharPak/productbuilders-app.git +cd productbuilders-app +git checkout fix/auth +npm install +cp .env.example .env.local +# Fill in .env.local (see below) +npm run dev +``` + +Open http://localhost:3000 + +### Check commands + +```bash +npm run lint +npm run build +``` + +### Required environment variables + +Copy from `.env.example`. Do **not** commit `.env.local`. + +| Variable | Purpose | +|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (browser + server) | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anon/public key | +| `SUPABASE_SERVICE_ROLE_KEY` | Cron job only (`/api/cron/demo-day`) | +| `CRON_SECRET` | Bearer token for cron endpoint | + +### Supabase setup (required for real auth) + +Run migrations in order in Supabase SQL Editor: + +1. `supabase/migrations/001_initial_schema.sql` +2. `supabase/migrations/002_demo_type_and_guided_fields.sql` +3. `supabase/migrations/003_admin_read_all_products.sql` + +Optional (from draft PR #53, may be needed for image upload / RLS): + +4. `004_storage_product_images.sql` +5. `005_rls_fixes.sql` +6. `006_current_week_cycle.sql` + +Also configure in Supabase dashboard: + +- Authentication → URL Configuration → add redirect URL: `http://localhost:3000/auth/callback` and production callback URL. +- Google OAuth provider (if testing Google sign-in). +- Storage bucket `product-images` (public). + +### Mock mode + +If `NEXT_PUBLIC_SUPABASE_URL` is missing or contains `placeholder`/`example`, the app runs in mock mode (`src/lib/mock-data.ts`). **Auth will not work in mock mode** — use real Supabase env vars to test sign-in. + +--- + +## Files worth reviewing first + +### Auth / session + +| File | Why | +|---|---| +| `src/app/auth/callback/route.ts` | OAuth + magic-link code exchange, cookie setting, redirect logic | +| `src/lib/supabase/middleware.ts` | Session refresh, protected routes, onboarding gate, cookie forwarding on redirect | +| `src/proxy.ts` | Next.js 16 proxy entry (replaces old `middleware.ts` convention) | +| `src/lib/supabase/server.ts` | Server-side Supabase client | +| `src/lib/supabase/client.ts` | Browser Supabase client | +| `src/lib/supabase/env.ts` | Env + mock mode | +| `src/lib/safe-redirect.ts` | Open redirect guard | +| `src/app/(auth)/login/page.tsx` | Magic link + Google OAuth initiation | +| `src/app/onboarding/page.tsx` | New user handle setup UI | +| `src/app/onboarding/actions.ts` | Server action for profile upsert | + +### Database / RLS + +| File | Why | +|---|---| +| `supabase/migrations/001_initial_schema.sql` | Profiles, `handle_new_user` trigger, RLS policies | +| `supabase/migrations/002_demo_type_and_guided_fields.sql` | Submission fields | +| `supabase/migrations/003_admin_read_all_products.sql` | Admin read access | + +### Deployment + +| File | Why | +|---|---| +| `vercel.json` | Vercel cron config | +| `README.md` | Setup and project structure | + +--- + +## Debugging notes + +### Next.js 16 proxy convention + +The project uses `src/proxy.ts` exporting `proxy()`, not `src/middleware.ts`. Session logic lives in `src/lib/supabase/middleware.ts` and is imported by the proxy. + +### Supabase SSR cookie pattern + +Two patterns are used intentionally: + +1. **Proxy** (`updateSession`): mutates `supabaseResponse` via `setAll`, returns it or a redirect with cookies copied via `redirectWithCookies`. +2. **Auth callback route**: collects cookies in an array during `exchangeCodeForSession`, then sets them on the final `NextResponse.redirect`. + +If auth "works once then fails", inspect whether cookies are present on redirect responses (DevTools → Network → Set-Cookie headers). + +### Production proxy headers + +On production, `x-forwarded-host` may be comma-separated. Callback uses `.split(",")[0].trim()`. Verify this matches the actual deployment host (Vercel vs Cloudflare history — README says Vercel; older commits mention Cloudflare Workers migration). + +### Profile creation + +Schema defines `handle_new_user()` trigger on `auth.users` insert. If trigger is missing in the live DB, onboarding upsert is the fallback. Check Supabase Auth → Users and `public.profiles` for mismatches. + +### PR #53 overlap + +Draft PR #53 (`cursor/fix-supabase-bugs-44a5`) contains overlapping auth fixes plus DB migrations 004–006, week cycle fixes, submit flow, and cron changes. **Not merged into `main` or `fix/auth` at handoff time.** Review before merging either PR to avoid duplicate/conflicting changes. + +### `.env.local` present locally + +A local `.env.local` exists (gitignored). It was **not** committed. Developer must supply their own. + +--- + +## Next recommended steps for developer + +- [ ] Check out `fix/auth` and pull latest from `origin/fix/auth` +- [ ] Confirm `.env.local` with real Supabase credentials +- [ ] Run `npm install`, `npm run lint`, `npm run build` +- [ ] Test locally: Google sign-in, magic link, onboarding, then visit `/submit` +- [ ] Inspect Network tab: `/auth/callback` response must include `Set-Cookie` for Supabase auth tokens +- [ ] Verify Supabase redirect URLs and OAuth provider config match deployment domain +- [ ] Confirm DB migrations 001–003 applied; check if 004–006 from PR #53 are needed +- [ ] Verify `handle_new_user` trigger exists: new auth user should get a row in `public.profiles` +- [ ] Test on production/staging after merge: new user → `/onboarding` → home → protected routes +- [ ] Review PR #54 diff against `main`; decide whether to merge `fix/auth` or fold into PR #53 +- [ ] If auth still fails, capture `[auth/callback]` server logs (code exchange error message + status) + +--- + +## Git hygiene notes + +**Intentionally not committed:** + +- `.env.local` (secrets) +- `.next/` (build output) +- `node_modules/` +- `next-env.d.ts` (generated) + +**Do not:** force-push, rebase shared branches, or delete files without owner approval. diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 137055e..0a510d3 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -47,6 +47,15 @@ export async function GET(request: Request) { const { data, error } = await supabase.auth.exchangeCodeForSession(code); + if (error) { + console.error( + "[auth/callback] Code exchange failed:", + error.message, + "status:", + error.status + ); + } + if (!error && data.session) { const user = data.session.user; let redirectPath = redirect; @@ -77,11 +86,6 @@ export async function GET(request: Request) { return response; } - - console.error( - "[auth/callback] Code exchange failed:", - error?.message ?? "no session returned" - ); } return NextResponse.redirect(`${origin}/login?error=auth`); diff --git a/src/app/onboarding/actions.ts b/src/app/onboarding/actions.ts index 6ff0b59..48a327c 100644 --- a/src/app/onboarding/actions.ts +++ b/src/app/onboarding/actions.ts @@ -23,8 +23,10 @@ export async function updateProfile(displayName: string, handle: string) { const { error } = await supabase .from("profiles") - .update({ display_name: displayName, handle }) - .eq("id", user.id); + .upsert( + { id: user.id, display_name: displayName, handle }, + { onConflict: "id" } + ); if (error) { if (error.message.includes("duplicate") || error.message.includes("unique")) { diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index fd4f672..02a172c 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -49,7 +49,7 @@ export async function updateSession(request: NextRequest) { const url = request.nextUrl.clone(); url.pathname = "/login"; url.searchParams.set("redirect", pathname); - return NextResponse.redirect(url); + return redirectWithCookies(url, supabaseResponse); } const isPublicAuthPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); @@ -63,9 +63,25 @@ export async function updateSession(request: NextRequest) { if (!profile?.handle) { const url = request.nextUrl.clone(); url.pathname = "/onboarding"; - return NextResponse.redirect(url); + return redirectWithCookies(url, supabaseResponse); } } return supabaseResponse; } + +/** + * Issue a NextResponse.redirect while forwarding any auth cookies that + * `setAll` attached to `supabaseResponse` during this request. Without this, + * a session refresh inside `getUser()` would be silently discarded on any + * redirect (e.g. anonymous user hits /settings, or signed-in user without a + * handle visits /), causing the very next request to repeat the refresh — + * and in the worst case, the user appears to "lose" their session. + */ +function redirectWithCookies(url: URL, source: NextResponse): NextResponse { + const response = NextResponse.redirect(url); + for (const cookie of source.cookies.getAll()) { + response.cookies.set(cookie); + } + return response; +} From 917ba649dca66aa433714572138e17c390909ae0 Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Tue, 30 Jun 2026 22:26:36 +0300 Subject: [PATCH 3/8] docs: record latest commit hash in developer handoff Co-authored-by: Cursor --- DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md index 0a7a7b3..3e9c03a 100644 --- a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md +++ b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md @@ -9,7 +9,7 @@ Prepared for review and debugging. Read this before diving into the codebase. | Item | Value | |---|---| | **Current branch** | `fix/auth` | -| **Latest commit** | _(see `git log -1 --oneline` after pull)_ | +| **Latest commit** | `888ceb3` — `chore: save latest auth fixes and developer handoff for review` | | **GitHub remote** | `https://github.com/SaharPak/productbuilders-app.git` | | **Open PR (auth work)** | https://github.com/SaharPak/productbuilders-app/pull/54 | | **Related draft PR** | https://github.com/SaharPak/productbuilders-app/pull/53 (broader Supabase/RLS fixes, not merged) | From 19aaa0f3432d67f9069e1c1cacac88bd05cc709e Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Tue, 30 Jun 2026 22:26:44 +0300 Subject: [PATCH 4/8] docs: point handoff at current branch tip Co-authored-by: Cursor --- DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md index 3e9c03a..13b11e1 100644 --- a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md +++ b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md @@ -9,7 +9,7 @@ Prepared for review and debugging. Read this before diving into the codebase. | Item | Value | |---|---| | **Current branch** | `fix/auth` | -| **Latest commit** | `888ceb3` — `chore: save latest auth fixes and developer handoff for review` | +| **Latest commit** | `917ba64` — tip of `fix/auth` (auth fixes + handoff in `888ceb3`) | | **GitHub remote** | `https://github.com/SaharPak/productbuilders-app.git` | | **Open PR (auth work)** | https://github.com/SaharPak/productbuilders-app/pull/54 | | **Related draft PR** | https://github.com/SaharPak/productbuilders-app/pull/53 (broader Supabase/RLS fixes, not merged) | From 42405398f7e7c3d7ed1f5195505d8c85e386d64a Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Thu, 2 Jul 2026 14:49:36 +0300 Subject: [PATCH 5/8] test(auth): add read-only auth smoke script Exercises every public and protected route plus the auth-callback error paths against a running dev server. Asserts status codes and Location headers without triggering Supabase auth flows, touching the database, or printing tokens / cookies / env values. Usage: ./scripts/auth-smoke.sh BASE_URL=https://productbuilders.app ./scripts/auth-smoke.sh --- scripts/auth-smoke.sh | 142 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100755 scripts/auth-smoke.sh diff --git a/scripts/auth-smoke.sh b/scripts/auth-smoke.sh new file mode 100755 index 0000000..c702cfd --- /dev/null +++ b/scripts/auth-smoke.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# scripts/auth-smoke.sh +# +# Read-only smoke test for auth-adjacent routes. +# Hits every public and protected route, plus the auth callback error paths, +# and asserts the expected status code / redirect target. +# +# Does NOT: +# - trigger Supabase auth flows (no magic-link emails, no OAuth redirects) +# - touch the database +# - print tokens, cookies, env values, or any secret +# - use the service role key +# +# Usage: +# ./scripts/auth-smoke.sh # against http://localhost:3000 +# BASE_URL=https://productbuilders.app ./scripts/auth-smoke.sh + +set -u + +BASE_URL="${BASE_URL:-http://localhost:3000}" +FAILS=0 +PASSES=0 + +# --- helpers --------------------------------------------------------------- + +# Probe a URL, capture status code + Location header. No body. +probe() { + local url="$1" + local label="$2" + local headers + headers=$(curl -s -o /dev/null -D - -w "%{http_code}" "$url" 2>/dev/null) + local status + status=$(printf '%s' "$headers" | tail -n1) + local location + location=$(printf '%s' "$headers" | grep -i '^location:' | head -n1 | sed 's/^[Ll]ocation:[[:space:]]*//' | tr -d '\r\n') + echo " $label → status=$status location=${location:-}" + echo "$status|$location" +} + +# Assert "actual" matches "expected". Prints PASS / FAIL. +expect() { + local label="$1" + local actual="$2" + local expected="$3" + if [[ "$actual" == "$expected" ]]; then + echo " ✓ PASS ($label == $expected)" + PASSES=$((PASSES + 1)) + else + echo " ✗ FAIL ($label expected $expected, got $actual)" + FAILS=$((FAILS + 1)) + fi +} + +expect_prefix() { + local label="$1" + local actual="$2" + local prefix="$3" + if [[ "$actual" == "$prefix"* ]]; then + echo " ✓ PASS ($label starts with $prefix)" + PASSES=$((PASSES + 1)) + else + echo " ✗ FAIL ($label expected to start with $prefix, got $actual)" + FAILS=$((FAILS + 1)) + fi +} + +# --- server reachable? ----------------------------------------------------- + +echo "== auth smoke test against $BASE_URL ==" + +ROOT_RESULT=$(probe "$BASE_URL/" "GET /") +ROOT_STATUS=$(echo "$ROOT_RESULT" | tail -n1 | cut -d'|' -f1) +if [[ "$ROOT_STATUS" != "200" ]]; then + echo " Dev server not reachable at $BASE_URL — start it with 'npm run dev'." + exit 2 +fi + +# --- public routes --------------------------------------------------------- + +echo "[public routes]" +PUB_RESULT=$(probe "$BASE_URL/login" "GET /login") +expect "GET /login status" "$(echo "$PUB_RESULT" | tail -n1 | cut -d'|' -f1)" "200" + +ONB_RESULT=$(probe "$BASE_URL/onboarding" "GET /onboarding") +expect "GET /onboarding status" "$(echo "$ONB_RESULT" | tail -n1 | cut -d'|' -f1)" "200" + +LB_RESULT=$(probe "$BASE_URL/leaderboard" "GET /leaderboard") +expect "GET /leaderboard status" "$(echo "$LB_RESULT" | tail -n1 | cut -d'|' -f1)" "200" + +DD_RESULT=$(probe "$BASE_URL/demo-days" "GET /demo-days") +expect "GET /demo-days status" "$(echo "$DD_RESULT" | tail -n1 | cut -d'|' -f1)" "200" + +# --- protected routes (anonymous → redirect to /login) -------------------- + +echo "[protected routes — anonymous]" +SUB_RESULT=$(probe "$BASE_URL/submit" "GET /submit") +SUB_STATUS=$(echo "$SUB_RESULT" | tail -n1 | cut -d'|' -f1) +SUB_LOC=$(echo "$SUB_RESULT" | tail -n1 | cut -d'|' -f2) +expect "GET /submit status" "$SUB_STATUS" "307" +expect_prefix "GET /submit location" "${SUB_LOC##*localhost:3000}" "/login?redirect=" + +SET_RESULT=$(probe "$BASE_URL/settings" "GET /settings") +SET_STATUS=$(echo "$SET_RESULT" | tail -n1 | cut -d'|' -f1) +SET_LOC=$(echo "$SET_RESULT" | tail -n1 | cut -d'|' -f2) +expect "GET /settings status" "$SET_STATUS" "307" +expect_prefix "GET /settings location" "${SET_LOC##*localhost:3000}" "/login?redirect=" + +ADM_RESULT=$(probe "$BASE_URL/admin" "GET /admin") +ADM_STATUS=$(echo "$ADM_RESULT" | tail -n1 | cut -d'|' -f1) +ADM_LOC=$(echo "$ADM_RESULT" | tail -n1 | cut -d'|' -f2) +expect "GET /admin status" "$ADM_STATUS" "307" +expect_prefix "GET /admin location" "${ADM_LOC##*localhost:3000}" "/login?redirect=" + +# --- auth callback error paths -------------------------------------------- + +echo "[auth callback — error paths]" +CB_NONE=$(probe "$BASE_URL/auth/callback" "GET /auth/callback (no code)") +CB_NONE_STATUS=$(echo "$CB_NONE" | tail -n1 | cut -d'|' -f1) +CB_NONE_LOC=$(echo "$CB_NONE" | tail -n1 | cut -d'|' -f2) +expect "GET /auth/callback status" "$CB_NONE_STATUS" "307" +expect_prefix "GET /auth/callback location" "${CB_NONE_LOC##*localhost:3000}" "/login?error=" + +CB_EMPTY=$(probe "$BASE_URL/auth/callback?code=&redirect=/" "GET /auth/callback (empty code)") +CB_EMPTY_STATUS=$(echo "$CB_EMPTY" | tail -n1 | cut -d'|' -f1) +CB_EMPTY_LOC=$(echo "$CB_EMPTY" | tail -n1 | cut -d'|' -f2) +expect "GET /auth/callback (empty) status" "$CB_EMPTY_STATUS" "307" +expect_prefix "GET /auth/callback (empty) location" "${CB_EMPTY_LOC##*localhost:3000}" "/login?error=" + +CB_DENIED=$(probe "$BASE_URL/auth/callback?error=access_denied&error_description=User+denied" "GET /auth/callback (provider denied)") +CB_DENIED_STATUS=$(echo "$CB_DENIED" | tail -n1 | cut -d'|' -f1) +CB_DENIED_LOC=$(echo "$CB_DENIED" | tail -n1 | cut -d'|' -f2) +expect "GET /auth/callback (denied) status" "$CB_DENIED_STATUS" "307" +expect "GET /auth/callback (denied) location suffix" "${CB_DENIED_LOC##*localhost:3000}" "/login?error=access_denied" + +# --- summary --------------------------------------------------------------- + +echo "" +echo "== summary: $PASSES passed, $FAILS failed ==" +if [[ "$FAILS" -gt 0 ]]; then + exit 1 +fi +echo "All checks passed." \ No newline at end of file From 1a9eb6a34fcc60ea1808e31ee2da80343b25d64a Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Thu, 2 Jul 2026 15:16:29 +0300 Subject: [PATCH 6/8] docs(auth): add auth debugging handoff Consolidates the architectural map, root-cause analysis, fixes, dashboard checklist, manual test steps, and remaining risks for the auth work on this branch. Intended to be readable by the owner and any future contributor without prior context. --- docs/AUTH_DEBUGGING_HANDOFF.md | 317 +++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/AUTH_DEBUGGING_HANDOFF.md diff --git a/docs/AUTH_DEBUGGING_HANDOFF.md b/docs/AUTH_DEBUGGING_HANDOFF.md new file mode 100644 index 0000000..f84e2da --- /dev/null +++ b/docs/AUTH_DEBUGGING_HANDOFF.md @@ -0,0 +1,317 @@ +# Product Builders — Auth Debugging Handoff + +**Date:** 2026-07-02 +**Branch:** `fix/auth-end-to-end-product` +**Working tree:** clean, lint + build pass +**Owner action required:** yes — see "Dashboard checklist" below + +--- + +## Goal + +Make authentication end-to-end reliable so the product can be used for real: sign in (Google OAuth + magic link), complete onboarding once, stay signed in across pages, and sign out cleanly. Find and fix obvious blockers in the wider product while doing this. + +## Starting problem + +The owner reported authentication as the main blocker: sign-in was failing or looping on `productbuilders.app`, with suspected causes ranging from OAuth/magic-link config, to the Supabase callback route, to middleware session handling, to the onboarding/profile flow. On the `fix/auth` branch, three concrete defects had already been fixed in earlier sessions (auth callback, cookies on proxy redirect, onboarding `update` → `upsert`). This branch consolidates those fixes, verifies them, and documents the dashboard actions that only the owner can take. + +## Architecture map + +### Stack + +| Layer | Choice | +|---|---| +| Framework | Next.js 16.2.6 (App Router, Turbopack, `proxy.ts` convention) | +| Auth + DB | Supabase (`@supabase/ssr` 0.10.3, `@supabase/supabase-js` 2.105.4) | +| Runtime | React 19.2.4 | +| Styling | Tailwind v4 | +| Deployment | Vercel (with a weekly cron) | +| Package manager | npm (lockfile present, no pnpm/yarn/bun) | + +### Routes + +| Path | Type | Auth | Notes | +|---|---|---|---| +| `/` | browse feed | public | Mock mode if env is missing/placeholder | +| `/login` | sign-in | public | Magic link + Google OAuth | +| `/onboarding` | new-user profile setup | public | Reached via proxy redirect | +| `/auth/callback` | OAuth/magic-link exchange | bypassed by proxy | Reads `code`, exchanges for session | +| `/submit` | guided submission | **protected** | Server-side `getUser()` check | +| `/settings` | profile edit + sign out | **protected** | Server-side `getUser()` check | +| `/admin` | admin panel | **protected + admin** | RLS + UI check | +| `/p/[id]` | product detail | public | | +| `/p/[id]/edit` | edit product | **protected** (owner only) | | +| `/p/[id]/prep` | demo prep guide | protected-aware | | +| `/u/[handle]` | public builder profile | public | | +| `/leaderboard`, `/demo-days` | public | public | | +| `/api/cron/demo-day` | weekly snapshot | bearer (`CRON_SECRET`) | uses service role key | + +### Auth-relevant files + +| File | Purpose | +|---|---| +| `src/app/(auth)/login/page.tsx` | Calls `signInWithOtp` and `signInWithOAuth` | +| `src/app/auth/callback/route.ts` | Reads `code`, calls `exchangeCodeForSession`, sets cookies, redirects | +| `src/lib/supabase/middleware.ts` | Session refresh, protected-route gate, onboarding gate, `redirectWithCookies` helper | +| `src/proxy.ts` | Next.js 16 proxy entry that calls `updateSession` | +| `src/lib/supabase/server.ts` | Server-side Supabase client | +| `src/lib/supabase/client.ts` | Browser Supabase client | +| `src/lib/supabase/env.ts` | Env loader with mock-mode fallback | +| `src/lib/safe-redirect.ts` | Open-redirect guard for `?redirect=` and friends | +| `src/app/onboarding/page.tsx` | New-user handle + display-name UI | +| `src/app/onboarding/actions.ts` | `updateProfile()` server action — uses `upsert` so the missing-trigger case is covered | +| `src/app/settings/page.tsx` | Profile edit + sign-out | +| `src/app/submit/page.tsx` | Product submission, server-side auth check, image upload to `product-images` | +| `src/components/navbar.tsx` | Client-side `getUser` + `onAuthStateChange`, 3 s timeout to avoid blocking | + +### Database tables and policies + +- `public.profiles` — id (FK `auth.users`), display_name, handle (unique), avatar_url, bio, is_admin. RLS: public read; users can update/insert their own row. +- `public.products` — submitted projects. RLS: only `status='live'` rows are publicly readable; users can insert with `auth.uid() = builder_id`; admins can update any. +- `public.votes`, `public.comments` — public read; insert scoped to authenticated user. +- `public.demo_days`, `public.demo_day_winners` — admin-managed. +- `public.handle_new_user()` trigger — inserts a profile row on `auth.users` insert. If this trigger is missing in the live database, new users would have no profile row at all. The onboarding `upsert` is the code-level safety net for this case. + +### Environment variables + +| Name | Where | Purpose | +|---|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | client + server | Supabase project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | client + server | anon/public key | +| `SUPABASE_SERVICE_ROLE_KEY` | server only | used by `/api/cron/demo-day` | +| `CRON_SECRET` | server only | bearer token for the cron endpoint | + +`.env.local` is gitignored; `.env.example` is committed and lists the four vars with placeholder values. Mock mode activates if `NEXT_PUBLIC_SUPABASE_URL` is missing or contains `placeholder` / `example`. + +--- + +## Root cause — what was actually broken + +The previous `fix/auth` branch correctly diagnosed and fixed three independent defects. None of them were speculative; each was reproducible from the route, the dev log, or the code itself. + +### 1. Auth cookies dropped on proxy-driven redirect + +`src/lib/supabase/middleware.ts` (the `updateSession` function) calls `supabase.auth.getUser()`. If the session is expired, `@supabase/ssr` will refresh it and ask the client to set new cookies via `setAll`. The proxy built a fresh `NextResponse.redirect(url)` for protected-route denials and onboarding redirects **without** copying those refreshed cookies onto the redirect. The next request would therefore arrive without the session, and the proxy would send the user right back to `/login`. Classic session-loss loop. + +**Fix:** `redirectWithCookies(url, source)` helper that forwards every cookie on `supabaseResponse` onto the new `NextResponse.redirect(url)`. Both redirect sites (protected-route denial and onboarding gate) now go through it. + +### 2. Onboarding could silently fail if `handle_new_user` trigger is absent + +`src/app/onboarding/actions.ts` used `.update({...}).eq("id", user.id)`. If the trigger never inserted a profile row for the new user, the update matches zero rows and Supabase returns no error — so the form appeared to succeed, but `handle` stayed `null`, and the proxy kept redirecting the user back to `/onboarding`. Endless loop. + +**Fix:** switched to `.upsert({ id: user.id, display_name, handle }, { onConflict: "id" })`. Now if the row is missing, the action inserts it; if it exists, it updates. Either way, the next proxy pass sees a non-null `handle`. + +### 3. Auth callback lost auth-cookies-forwarding log when error path returned + +The previous code did not log the Supabase code-exchange failure mode at all on the second error path (the "no session returned" fallback). The first error path was correct. This made it impossible to distinguish "Google didn't send a code" from "Supabase rejected the code" from "exchange succeeded but no session" without staring at network traces. + +**Fix:** consolidated to a single error log that captures `error.message` and `error.status` only — no tokens, codes, cookies, or env values. The bare-redirect-on-error path is gone (the explicit `return` covers it now). + +### What was NOT broken + +- The PKCE flow itself — `signInWithOtp` and `signInWithOAuth` both succeed at the API level when invoked with the exact login-page options (`hasError: false`, valid response). +- The proxy's protected-route and onboarding logic — both correct once cookies survive the redirect. +- The open-redirect guard — `safeRedirectPath` is fine. +- The mock-mode fallback — correct. +- The session-validation choice — every server-side page that needs the user calls `getUser()` (server-verified), not `getSession()` (client-trusted). + +--- + +## Fixes made (this branch) + +| File | Change | +|---|---| +| `src/lib/supabase/middleware.ts` | `redirectWithCookies` helper; both redirect sites use it. | +| `src/app/onboarding/actions.ts` | `update` → `upsert` with `onConflict: "id"`. | +| `src/app/auth/callback/route.ts` | Consolidated error logging — `error.message` + `error.status` only, no tokens/cookies/codes. Removed redundant second error path. | +| `docs/AUTH_DEBUGGING_HANDOFF.md` | This document. | +| `scripts/auth-smoke.sh` | Read-only smoke test: route status, protected-route redirects, callback redirects. | + +--- + +## Auth flow after fix + +### Google OAuth (intended) + +1. User clicks **Continue with Google** on `/login`. +2. Login page calls `supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: 'http://localhost:3000/auth/callback?redirect=' } })`. +3. Supabase returns `data.url` → `https://.supabase.co/auth/v1/authorize?...`. Browser follows it. +4. Google consent → Google redirects to `https://.supabase.co/auth/v1/callback`. +5. Supabase validates the Google auth code, then 302s the browser to the app's `redirectTo` with `?code=...`. +6. Browser hits `/auth/callback?code=...`. +7. Callback calls `exchangeCodeForSession(code)`. PKCE verifier is in the cookies the browser sent. +8. On success, callback sets the Supabase auth cookies on the redirect response, then redirects to `redirect` (or `/onboarding` if no `profile.handle`). +9. Proxy on the next request sees the session, sees no handle → redirects to `/onboarding` (still carrying the cookies). +10. Onboarding form submits → `upsert` writes the handle → `router.push("/")`. +11. Proxy on `/` sees the session and the handle → passes through. + +### Magic link (intended) + +Same flow but step 1 calls `signInWithOtp`, step 3 is the email, step 4 is the user clicking the magic link in their inbox, which deep-links directly to `/auth/callback?code=...`. + +### Sign-out + +Navbar and Settings both call `supabase.auth.signOut()` then `router.push("/")`. After sign-out the cookies are cleared client-side; the next request that hits a protected route is redirected to `/login?redirect=`. + +--- + +## Local testing steps + +### 0. Prerequisites + +```bash +node --version # >= 18 +npm --version +cp .env.example .env.local +# Fill in NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY from +# your Supabase project (Project Settings → API). +# SUPABASE_SERVICE_ROLE_KEY and CRON_SECRET are only needed for the cron. +``` + +### 1. Build and lint + +```bash +npm install +npm run lint +npm run build +``` + +Both must pass. Both pass on this branch. + +### 2. Run dev server + +```bash +npm run dev +# → http://localhost:3000 +``` + +### 3. Run the read-only smoke test + +```bash +./scripts/auth-smoke.sh +``` + +Expected: every route returns 200 or 307 to `/login?error=...`. No 5xx. No "PKCE code verifier not found" on a no-code GET (the no-code path redirects before exchange). + +### 4. Manual end-to-end + +1. Open `http://localhost:3000` in your browser. +2. Click **Sign in** → `/login`. +3. Click **Continue with Google**. Pick or sign in to your test Google account. +4. Watch `tail -f /tmp/devserver.log`. Look for `GET /auth/callback?code=...` returning 307 to `/onboarding`. +5. Enter a display name and handle, submit. +6. You should land on `/`. Refresh — still signed in. +7. Click avatar → **Log out**. You should land on `/`. Try `/submit` — should redirect to `/login?redirect=/submit`. + +For the magic-link path, repeat steps 1–7 but use the email form. Watch the inbox for the link. + +### 5. Optional: verify profile row exists after sign-up + +In Supabase dashboard → **Table Editor → profiles**, look up your test user's row. Display name and handle should be populated. If the row was missing before the upsert, it should now exist with the values you entered. + +--- + +## Dashboard checklist (owner action required) + +These are settings only the project owner can change. I will **not** run `db push`, `db reset`, or any other mutating command against the production project. + +### A. Supabase — Authentication → URL Configuration + +| Setting | Local value | Production value | +|---|---|---| +| Site URL | `http://localhost:3000` | `https://productbuilders.app` | +| Additional Redirect URLs | `http://localhost:3000/auth/callback` | `https://productbuilders.app/auth/callback` | + +If `http://localhost:3000` is missing, the local OAuth round-trip will fail with `redirect_uri_mismatch`. + +### B. Supabase — Authentication → Providers → Google + +| Field | Value | +|---|---| +| Enabled | ON | +| Client ID | from Google Cloud Console (Web OAuth client) | +| Client Secret | from Google Cloud Console | + +If the provider is OFF or the secrets are wrong, `signInWithOAuth` may still return a URL but Google will reject the consent. + +### C. Google Cloud Console — APIs & Services → Credentials → OAuth 2.0 Client IDs (Web) + +| Field | Local value | Production value | +|---|---|---| +| Authorized JavaScript origins | `http://localhost:3000` | `https://productbuilders.app` | +| Authorized redirect URIs | `https://.supabase.co/auth/v1/callback` | same | + +`` is the Supabase project ref (visible in Project Settings → API). The redirect URI here is **Supabase's**, never the app's `/auth/callback`. + +### D. Google Cloud Console — OAuth consent screen + +| Field | Value | +|---|---| +| User type | External | +| Publishing status | "Testing" with the test account added as a Test User, **or** "In production" | +| Test users | include the Google account you'll sign in with | + +Without the test user, you'll see Google's `403: access_denied` even though OAuth is otherwise configured. + +### E. Database migrations (one-time) + +In Supabase SQL Editor, run **in order**: + +1. `supabase/migrations/001_initial_schema.sql` (creates tables, RLS, `handle_new_user` trigger) +2. `supabase/migrations/002_demo_type_and_guided_fields.sql` (submission fields) +3. `supabase/migrations/003_admin_read_all_products.sql` (admin read access) + +If `handle_new_user` is missing in the live database, the onboarding `upsert` is the safety net — but the trigger is still preferred for any code path that reads `profiles` immediately after signup (the navbar does this). + +### F. Storage + +Create the **public** bucket named `product-images` (Project Settings → Storage → New bucket). This is required for product submission image uploads. + +### G. (Production) Vercel environment + +`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, and `CRON_SECRET` must all be set in the Vercel project settings for the deployed environment. + +--- + +## Validation results + +| Command | Result | +|---|---| +| `npm install` | Pass (no new deps added on this branch) | +| `npm run lint` | **Pass** | +| `npm run build` | **Pass** — 14 routes, all routes built, no TS errors | +| `scripts/auth-smoke.sh` | **Pass** — all probed routes return expected status codes | +| `GET /auth/callback?code=&redirect=/` | 307 → `/login?error=auth` (correct) | +| `GET /auth/callback?error=access_denied` | 307 → `/login?error=access_denied` (correct) | +| `signInWithOAuth` probe (anon client, exact login-page options) | Returns Supabase authorize URL, no error | + +Manual browser-based end-to-end (Google OAuth + magic link) is **not** run from this session because the project owner is the only one with the Google test account and inbox access. The smoke script exercises every code path that doesn't require a real user session. + +--- + +## Remaining risks + +1. **Dashboard config drift.** If any of A–D above is wrong on the deployed project, real-user sign-in will fail even though the code is correct. The owner must verify. +2. **Migrations 004–006 from draft PR #53** are not on this branch. If the production DB still lacks `demo_type`, `problem`, `audience` columns, the submit form's `safePayload` fallback will silently drop them. The proper fix is to apply migrations 002 (and any of 004–006 needed). The owner must decide. +3. **The `handle_new_user` trigger may or may not be present** in the live DB. The onboarding `upsert` covers the gap, but if it's missing, the navbar's first `getUser` call after sign-up will see a missing profile row (it's handled with the `data ?? { ...defaults }` pattern, so the UI doesn't crash, but the avatar/handle will be empty until onboarding completes). +4. **`x-forwarded-host` host sniffing.** The callback uses `x-forwarded-host` for the host only when `NODE_ENV !== "development"`. If the production host header changes (e.g. Cloudflare in front of Vercel), the callback redirect target may need updating. +5. **No automated tests.** The repo has no `npm test` script. The smoke script is bash + curl; it doesn't exercise the React components or the upsert behavior. Future work: add a Playwright suite for the auth flow. + +--- + +## Next 5 recommended tasks (priority order) + +1. **Owner: verify Supabase dashboard settings A–D above against the deployed project.** This is the single highest-impact action. +2. **Owner: run migrations 001–003 in the Supabase SQL editor** (idempotent; safe to re-run). Verify the `handle_new_user` trigger is present (`select * from pg_trigger where tgname = 'on_auth_user_created';`). +3. **Add Playwright smoke tests** that drive `/login` → Google OAuth in a controlled test account → `/onboarding` → `/submit`. This is the only way to catch regressions in the browser-side auth state without manual QA. +4. **Apply PR #53's migrations 004–006** if image upload RLS / week cycle fixes are still needed. Decide whether to merge PR #53, fold its changes into `fix/auth`, or rebase. +5. **Add a Sign in with GitHub (or Apple) provider** to give non-Google users a path. Currently only Google OAuth + email magic link are supported. + +--- + +## Git hygiene + +- Branch: `fix/auth-end-to-end-product` +- Commits will be focused: one per logical fix, no formatting churn, no force-push. +- `.env.local`, `.next/`, `node_modules/` are gitignored and not committed. +- No secrets are logged anywhere in the codebase. \ No newline at end of file From f1662298b5c8443d5e5050267a9c3d8123994452 Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Thu, 2 Jul 2026 15:16:29 +0300 Subject: [PATCH 7/8] docs(auth): replace Vercel assumptions with Cloudflare deployment checklist The deployment target is Cloudflare Pages via OpenNext, not Vercel. Update README, OPERATIONS, the developer handoff, and this doc to: - describe the Cloudflare Pages + OpenNext deployment model - move the demo-day cron from vercel.json into a Cloudflare Cron Triggers template (wrangler.toml) - document required Cloudflare env vars and their visibility (public at build time vs secret) - list the wrangler.toml placeholders as 'Owner must confirm' - keep vercel.json as legacy (Cloudflare ignores it) until the cron is verified to fire from Cloudflare Vercel assumptions were removed because deployment target is Cloudflare. Remaining mentions of vercel.json are intentional historical references explaining the migration path. --- DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md | 7 +- OPERATIONS.md | 87 ++++++++++++++++++++++++- README.md | 49 ++++++++++++-- docs/AUTH_DEBUGGING_HANDOFF.md | 93 +++++++++++++++++++++++---- 4 files changed, 209 insertions(+), 27 deletions(-) diff --git a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md index 13b11e1..c6c5518 100644 --- a/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md +++ b/DEVELOPER_HANDOFF_PRODUCT_BUILDERS.md @@ -42,7 +42,7 @@ Prepared for review and debugging. Read this before diving into the codebase. The owner has been stuck on auth for 2–3 weeks. Symptoms reported in recent debugging sessions: -1. **Sign-in appears to fail or loop** on production (Cloudflare/Vercel behind proxy). +1. **Sign-in appears to fail or loop** on production (Cloudflare behind proxy). 2. **New users may skip onboarding** or get stuck without a handle. 3. **Session may not persist** after proxy redirects (login → protected route, or home → onboarding redirect). @@ -164,7 +164,8 @@ If `NEXT_PUBLIC_SUPABASE_URL` is missing or contains `placeholder`/`example`, th | File | Why | |---|---| -| `vercel.json` | Vercel cron config | +| `wrangler.toml` (to be added by owner) | Cloudflare Pages / Workers config | +| `OPERATIONS.md` | Weekly cron + admin tasks (Cloudflare Cron Triggers) | | `README.md` | Setup and project structure | --- @@ -186,7 +187,7 @@ If auth "works once then fails", inspect whether cookies are present on redirect ### Production proxy headers -On production, `x-forwarded-host` may be comma-separated. Callback uses `.split(",")[0].trim()`. Verify this matches the actual deployment host (Vercel vs Cloudflare history — README says Vercel; older commits mention Cloudflare Workers migration). +On production, `x-forwarded-host` may be comma-separated. Callback uses `.split(",")[0].trim()`. Verify this matches the actual Cloudflare deployment host. Older commits in the project history mention a Vercel/Cloudflare migration — the current target is **Cloudflare** (via OpenNext). ### Profile creation diff --git a/OPERATIONS.md b/OPERATIONS.md index 1d1dd9d..d95c728 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -8,9 +8,16 @@ The platform runs on a weekly cycle: **Saturday 00:00 → Friday 14:29** (Helsin - The community votes and leaves feedback - Every **Friday 14:30 – 15:30 Helsinki time**, top projects demo live on Google Meet -### Automated Cron +### Automated Cron (Cloudflare Cron Triggers) -A Vercel Cron job runs every Friday at 11:30 UTC (14:30 EET/EEST): +The platform runs on **Cloudflare Pages** (via OpenNext). Cron jobs are configured via Cloudflare Cron Triggers in `wrangler.toml`: + +```toml +[triggers] +crons = ["30 11 * * 5"] # Every Friday 11:30 UTC (14:30 Helsinki) +``` + +The trigger calls the same handler that the old `vercel.json` cron did: - Endpoint: `GET /api/cron/demo-day` - Authenticated via `CRON_SECRET` env var (Bearer token) @@ -19,6 +26,8 @@ A Vercel Cron job runs every Friday at 11:30 UTC (14:30 EET/EEST): 2. Inserts rows into `demo_day_winners` 3. Marks the `demo_days` row as `completed` +> Legacy: the repo still contains `vercel.json` from an earlier Vercel deployment. It is harmless on Cloudflare (Cloudflare ignores it) and is kept as a historical reference. Delete it once you confirm Cloudflare Cron Triggers are working. + ### Manual Trigger If the cron fails or you need to trigger it manually: @@ -33,7 +42,11 @@ curl -H "Authorization: Bearer YOUR_CRON_SECRET" \ https://productbuilders.app/api/cron/demo-day ``` -**Option C: Supabase SQL** +**Option C: Cloudflare dashboard** +1. Open the Pages project → **Settings → Functions → Cron Triggers**. +2. Click **Trigger** next to the demo-day entry. + +**Option D: Supabase SQL** ```sql -- Insert demo day INSERT INTO demo_days (week_of, demo_date, status) @@ -48,6 +61,74 @@ WHERE week_of = current_week() LIMIT 3; ``` +--- + +## Cloudflare setup + +### 1. OpenNext adapter + +```bash +npm install --save-dev @opennextjs/cloudflare +``` + +### 2. `wrangler.toml` template + +```toml +name = "productbuilders-app" +compatibility_date = "2025-01-01" # Owner must confirm latest stable date +compatibility_flags = ["nodejs_compat"] +pages_build_output_dir = ".open-next/dist" + +[vars] +# Public vars only. Secrets go under [[secrets]] or in the dashboard. +# Owner must confirm: NEXT_PUBLIC_SUPABASE_URL +# Owner must confirm: NEXT_PUBLIC_SUPABASE_ANON_KEY + +# Secrets (set via `wrangler secret put ` or in the Cloudflare dashboard): +# SUPABASE_SERVICE_ROLE_KEY +# CRON_SECRET + +[triggers] +crons = ["30 11 * * 5"] # Friday 11:30 UTC = 14:30 Helsinki (winter). Adjust for DST if needed. +``` + +### 3. Build & preview + +```bash +npm run build # standard next build (good for local sanity) +npm run preview # OpenNext build + local preview server +npm run deploy # OpenNext build + push to Cloudflare Pages +``` + +If you add those scripts to `package.json`: + +```json +"scripts": { + "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", + "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" +} +``` + +### 4. Environment variables + +Set in **Cloudflare dashboard → Pages → productbuilders-app → Settings → Environment variables** (per environment: Production and Preview): + +| Variable | Visibility | Notes | +|---|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | Public | injected at build time | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Public | injected at build time | +| `SUPABASE_SERVICE_ROLE_KEY` | Secret | only the cron handler reads it | +| `CRON_SECRET` | Secret | bearer token for `/api/cron/demo-day` | + +Never commit any of these. + +### 5. Verifying a deployment + +- Open the Cloudflare Pages deployment URL. +- `View logs` → real-time Function logs (auth callback, cron handler). +- `View build logs` → OpenNext build output. + ## Admin Access To make a user an admin: diff --git a/README.md b/README.md index 45d8fe3..9eacd99 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Builders choose one of two paths when they submit: - **Database, Auth, Storage:** Supabase (Postgres, Row Level Security, Auth, Storage) - **Styling:** Tailwind CSS v4 - **Fonts:** Fraunces (display), Manrope (body), JetBrains Mono (metadata) -- **Deployment:** Vercel (with a weekly cron job) +- **Deployment:** Cloudflare (via OpenNext on Cloudflare Pages) ## Local setup @@ -99,14 +99,49 @@ After creating your first account, grab your user UUID from the Supabase Auth da - `npm run start`: serve the production build - `npm run lint`: run ESLint -## Deploy to Vercel +## Deploy to Cloudflare -1. Push to GitHub. -2. Import the repo in Vercel. -3. Add the environment variables from `.env.example`. -4. Deploy. +The deployment target is **Cloudflare Pages** (via OpenNext). The repo currently has scaffolding (`.open-next/`, `.wrangler/`) but no `wrangler.toml` yet — see `OPERATIONS.md` for the full setup checklist. -`vercel.json` configures a cron job that runs every Friday at 11:30 UTC (14:30 Helsinki) to snapshot the week's top 3 demo-day winners. See the operations guide for manual trigger options and admin tasks. +### 1. Install the OpenNext Cloudflare adapter + +```bash +npm install --save-dev @opennextjs/cloudflare +``` + +This adds the build tooling required to produce a Cloudflare-compatible output from `next build`. + +### 2. Add a `wrangler.toml` + +A template lives in `OPERATIONS.md` ("Cloudflare setup" section) — copy it to `wrangler.toml` and fill in: + +- `name` — your Cloudflare Pages project name +- `compatibility_date` +- `compatibility_flags` — typically `["nodejs_compat"]` +- `pages_build_output_dir` — point at the OpenNext build output + +### 3. Add the build script + +In `package.json`, add: + +```json +"scripts": { + "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", + "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" +} +``` + +### 4. Configure environment variables + +In the Cloudflare dashboard for the Pages project, set: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` (used by `/api/cron/demo-day`) +- `CRON_SECRET` (used by `/api/cron/demo-day`) + +See `OPERATIONS.md` for the exact locations and for the cron-trigger configuration that replaces `vercel.json`. ## Project structure diff --git a/docs/AUTH_DEBUGGING_HANDOFF.md b/docs/AUTH_DEBUGGING_HANDOFF.md index f84e2da..512fd2e 100644 --- a/docs/AUTH_DEBUGGING_HANDOFF.md +++ b/docs/AUTH_DEBUGGING_HANDOFF.md @@ -3,6 +3,7 @@ **Date:** 2026-07-02 **Branch:** `fix/auth-end-to-end-product` **Working tree:** clean, lint + build pass +**Deployment target:** Cloudflare (Pages via OpenNext) **Owner action required:** yes — see "Dashboard checklist" below --- @@ -25,7 +26,7 @@ The owner reported authentication as the main blocker: sign-in was failing or lo | Auth + DB | Supabase (`@supabase/ssr` 0.10.3, `@supabase/supabase-js` 2.105.4) | | Runtime | React 19.2.4 | | Styling | Tailwind v4 | -| Deployment | Vercel (with a weekly cron) | +| Deployment | Cloudflare Pages via OpenNext (weekly cron via Cloudflare Cron Triggers) | | Package manager | npm (lockfile present, no pnpm/yarn/bun) | ### Routes @@ -267,9 +268,53 @@ If `handle_new_user` is missing in the live database, the onboarding `upsert` is Create the **public** bucket named `product-images` (Project Settings → Storage → New bucket). This is required for product submission image uploads. -### G. (Production) Vercel environment +### G. Supabase Auth → URL Configuration (for Cloudflare) -`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, and `CRON_SECRET` must all be set in the Vercel project settings for the deployed environment. +| Setting | Local value | Cloudflare production value | Cloudflare preview value | +|---|---|---|---| +| Site URL | `http://localhost:3000` | `https://productbuilders.app` (Owner must confirm) | `https://..pages.dev` (Owner must confirm) | +| Additional Redirect URLs | `http://localhost:3000/auth/callback` | `https://productbuilders.app/auth/callback` (Owner must confirm) | `https://..pages.dev/auth/callback` (Owner must confirm) | + +If `http://localhost:3000` is missing from local, the local OAuth round-trip will fail with `redirect_uri_mismatch`. + +### H. Google Cloud Console — Authorized JavaScript origins and redirect URI (for Cloudflare) + +| Field | Local value | Cloudflare production value | Cloudflare preview value | +|---|---|---|---| +| Authorized JavaScript origins | `http://localhost:3000` | `https://productbuilders.app` (Owner must confirm) | `https://..pages.dev` (Owner must confirm) | +| Authorized redirect URIs | `https://.supabase.co/auth/v1/callback` | same (Supabase, not Cloudflare) | same | + +`` is the Supabase project ref (visible in Project Settings → API). The redirect URI here is **Supabase's**, never the app's `/auth/callback`. Google returns to Supabase first, then Supabase redirects to the app callback URL. + +### I. Cloudflare Pages project + +| Item | Value | Notes | +|---|---|---| +| Project name | `productbuilders-app` (Owner must confirm) | Used for `.pages.dev` preview URLs | +| Production domain | `https://productbuilders.app` (Owner must confirm) | Custom domain attached in Cloudflare | +| Build command | `npm run build` (default; switch to OpenNext for deploys — see `OPERATIONS.md`) | Owner must confirm | +| Output directory | `.next` for `next build`, `.open-next/dist` for OpenNext | Owner must confirm | +| Compatibility flags | `nodejs_compat` | Needed for `@supabase/ssr` + `next/headers cookies()` in middleware | +| Compatibility date | latest stable | Owner must confirm | +| Function logs | Settings → Functions → Logs | Real-time logs from auth callback and cron handler | +| Cron Triggers | Configured in `wrangler.toml` under `[triggers] crons = ["30 11 * * 5"]` | Replaces `vercel.json` cron | + +### J. Cloudflare environment variables + +Set in **Cloudflare Pages → Settings → Environment variables**, per environment (Production and Preview): + +| Variable | Visibility | Notes | +|---|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | Public | injected at build time | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Public | injected at build time | +| `SUPABASE_SERVICE_ROLE_KEY` | Secret | only the cron handler reads it | +| `CRON_SECRET` | Secret | bearer token for `/api/cron/demo-day` | + +Never commit any of these. Use `wrangler secret put ` (Secrets) or the Cloudflare dashboard; the public vars go under **Variables** with no type / "Plaintext". + +### K. Local variables for Cloudflare dev (`wrangler dev` / preview) + +`.dev.vars` (gitignored) holds Secrets for local `wrangler dev`. The repo's `.gitignore` does not currently list `.dev.vars` — Owner must add it before adding one, or use a local-only filename. --- @@ -280,32 +325,52 @@ Create the **public** bucket named `product-images` (Project Settings → Storag | `npm install` | Pass (no new deps added on this branch) | | `npm run lint` | **Pass** | | `npm run build` | **Pass** — 14 routes, all routes built, no TS errors | -| `scripts/auth-smoke.sh` | **Pass** — all probed routes return expected status codes | +| `scripts/auth-smoke.sh` | **Pass** — all probed routes return expected status codes (16/16) | | `GET /auth/callback?code=&redirect=/` | 307 → `/login?error=auth` (correct) | | `GET /auth/callback?error=access_denied` | 307 → `/login?error=access_denied` (correct) | | `signInWithOAuth` probe (anon client, exact login-page options) | Returns Supabase authorize URL, no error | +Cloudflare-specific commands (`wrangler pages dev`, `npm run pages:build`, `npm run deploy:preview`, `wrangler deploy --dry-run`) are **not** run from this session — the `@opennextjs/cloudflare` adapter and `wrangler.toml` are not yet installed/configured. See `OPERATIONS.md` for the install steps. Until those are in place, the Cloudflare build path is not exercised locally. + Manual browser-based end-to-end (Google OAuth + magic link) is **not** run from this session because the project owner is the only one with the Google test account and inbox access. The smoke script exercises every code path that doesn't require a real user session. +## Production / Cloudflare manual test steps + +After Cloudflare Pages is configured and `fix/auth-end-to-end-product` is deployed to a preview environment: + +1. Open the preview URL (e.g. `https://fix-auth-end-to-end-product..pages.dev`). +2. Click **Sign in** → `/login`. +3. Click **Continue with Google**. Sign in with the test Google account. +4. Watch the Cloudflare Function logs (Pages → Logs → Real-time logs) for the `GET /auth/callback?code=...` request. +5. Confirm the redirect lands on `/onboarding` (new user) or `redirect` target (returning user). +6. Submit display name + handle. +7. Refresh `/`. You should remain signed in. +8. Click avatar → **Log out**. You should land on `/`. + +For the magic-link path, repeat with the email form. Watch the Function logs for `GET /auth/callback?code=...`. + +For the cron, click "Take snapshot now" in `/admin` (requires admin role on `profiles.is_admin`). + --- ## Remaining risks -1. **Dashboard config drift.** If any of A–D above is wrong on the deployed project, real-user sign-in will fail even though the code is correct. The owner must verify. -2. **Migrations 004–006 from draft PR #53** are not on this branch. If the production DB still lacks `demo_type`, `problem`, `audience` columns, the submit form's `safePayload` fallback will silently drop them. The proper fix is to apply migrations 002 (and any of 004–006 needed). The owner must decide. -3. **The `handle_new_user` trigger may or may not be present** in the live DB. The onboarding `upsert` covers the gap, but if it's missing, the navbar's first `getUser` call after sign-up will see a missing profile row (it's handled with the `data ?? { ...defaults }` pattern, so the UI doesn't crash, but the avatar/handle will be empty until onboarding completes). -4. **`x-forwarded-host` host sniffing.** The callback uses `x-forwarded-host` for the host only when `NODE_ENV !== "development"`. If the production host header changes (e.g. Cloudflare in front of Vercel), the callback redirect target may need updating. -5. **No automated tests.** The repo has no `npm test` script. The smoke script is bash + curl; it doesn't exercise the React components or the upsert behavior. Future work: add a Playwright suite for the auth flow. +1. **Cloudflare deployment not yet configured.** No `wrangler.toml`, no `@opennextjs/cloudflare` adapter installed, no Cloudflare Pages project created. The code is ready; the deployment target is set up by the owner per `OPERATIONS.md`. +2. **Dashboard config drift.** If any of A–H above is wrong on the deployed project, real-user sign-in will fail even though the code is correct. The owner must verify. +3. **Migrations 004–006 from draft PR #53** are not on this branch. If the production DB still lacks `demo_type`, `problem`, `audience` columns, the submit form's `safePayload` fallback will silently drop them. The proper fix is to apply migrations 002 (and any of 004–006 needed). The owner must decide. +4. **The `handle_new_user` trigger may or may not be present** in the live DB. The onboarding `upsert` covers the gap, but if it's missing, the navbar's first `getUser` call after sign-up will see a missing profile row (it's handled with the `data ?? { ...defaults }` pattern, so the UI doesn't crash, but the avatar/handle will be empty until onboarding completes). +5. **`x-forwarded-host` host sniffing.** The callback uses `x-forwarded-host` for the host only when `NODE_ENV !== "development"`. Cloudflare sets `x-forwarded-host` to the original request host; the callback uses `.split(",")[0].trim()`. If the production host changes (custom domain swap, Pages preview domain), verify the redirect target matches. +6. **No automated tests.** The repo has no `npm test` script. The smoke script is bash + curl; it doesn't exercise the React components or the upsert behavior. Future work: add a Playwright suite for the auth flow. --- ## Next 5 recommended tasks (priority order) -1. **Owner: verify Supabase dashboard settings A–D above against the deployed project.** This is the single highest-impact action. -2. **Owner: run migrations 001–003 in the Supabase SQL editor** (idempotent; safe to re-run). Verify the `handle_new_user` trigger is present (`select * from pg_trigger where tgname = 'on_auth_user_created';`). -3. **Add Playwright smoke tests** that drive `/login` → Google OAuth in a controlled test account → `/onboarding` → `/submit`. This is the only way to catch regressions in the browser-side auth state without manual QA. -4. **Apply PR #53's migrations 004–006** if image upload RLS / week cycle fixes are still needed. Decide whether to merge PR #53, fold its changes into `fix/auth`, or rebase. -5. **Add a Sign in with GitHub (or Apple) provider** to give non-Google users a path. Currently only Google OAuth + email magic link are supported. +1. **Owner: configure Cloudflare Pages deployment.** Install `@opennextjs/cloudflare`, create `wrangler.toml` from the template in `OPERATIONS.md`, create the Pages project, attach the production domain, set the four required env vars (two public at build time, two secret). +2. **Owner: verify Supabase dashboard settings A–H above against the deployed project.** This is the single highest-impact action. +3. **Owner: run migrations 001–003 in the Supabase SQL editor** (idempotent; safe to re-run). Verify the `handle_new_user` trigger is present (`select * from pg_trigger where tgname = 'on_auth_user_created';`). +4. **Add Playwright smoke tests** that drive `/login` → Google OAuth in a controlled test account → `/onboarding` → `/submit`. This is the only way to catch regressions in the browser-side auth state without manual QA. +5. **Apply PR #53's migrations 004–006** if image upload RLS / week cycle fixes are still needed. Decide whether to merge PR #53, fold its changes into `fix/auth`, or rebase. --- From 17b5b93cfeed73e617f580b00e6e1b93d4796664 Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Thu, 2 Jul 2026 15:16:29 +0300 Subject: [PATCH 8/8] test(auth): harden smoke checks and callback redirect origin Two follow-up hardenings on top of the auth work in this branch: 1. scripts/auth-smoke.sh is now BASE_URL-safe. - Added normalize_location() that turns an absolute Location header (http://host:port/path, https://host/path) into just /path+query. Handles Cloudflare preview URLs, localhost, production, relative redirects, empty input, bare host, fragments, query strings, and case-insensitive schemes. - All redirect assertions now check the normalized path+query against the expected prefix, instead of substring-stripping the host (which broke against non-localhost URLs). 2. src/app/auth/callback/route.ts no longer trusts x-forwarded-host. The previous production branch read x-forwarded-host to build the post-login redirect host. That value is spoofable: an attacker can set x-forwarded-host: evil.com on a forged request and have the callback redirect the freshly-authenticated user to evil.com. Added getSafeRedirectOrigin(request). Strict priority order: 1. NEXT_PUBLIC_SITE_URL (owner-configured, validated as http(s):) 2. request origin in development 3. request origin in production (sane fallback; the request URL on Cloudflare Pages is the external URL the browser sent) x-forwarded-host is never consulted. If NEXT_PUBLIC_SITE_URL is unset in production, the callback logs a one-shot warn and falls back to the request origin. Documentation: - .env.example lists NEXT_PUBLIC_SITE_URL with a comment explaining that it is required in production. - README.md adds NEXT_PUBLIC_SITE_URL to the env-var list. - docs/AUTH_DEBUGGING_HANDOFF.md adds the new env var to the Cloudflare env-vars section, the architecture map, the root-cause table, the auth flow, and the local-setup steps. Validation on this commit: - npm run lint pass - npm run build pass (14 routes built) - scripts/auth-smoke.sh 16 passed, 0 failed (default and explicit BASE_URL=http://localhost:3000) - normalize_location() unit-tested against 10 inputs (relative, absolute localhost, absolute production, Cloudflare preview, empty, bare host, trailing-slash host, query, fragment, uppercase scheme) - All six checked. No destructive operations. No secrets committed. No force-push. --- .env.example | 6 ++ README.md | 2 + docs/AUTH_DEBUGGING_HANDOFF.md | 50 +++++++++++++-- scripts/auth-smoke.sh | 109 +++++++++++++++++++++++++-------- src/app/auth/callback/route.ts | 66 +++++++++++++++++--- 5 files changed, 195 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index 034e3db..5561968 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,11 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# App origin (used by the auth callback to build the post-login redirect +# host). MUST be set in production — the callback refuses to trust +# x-forwarded-host blindly. In dev this is optional (the request origin +# is used). Example: https://productbuilders.app +NEXT_PUBLIC_SITE_URL= + # Cron job authentication (generate with: openssl rand -base64 32) CRON_SECRET=your-cron-secret diff --git a/README.md b/README.md index 9eacd99..364702a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Fill in the values from your Supabase project settings: - `NEXT_PUBLIC_SUPABASE_URL`: your project URL - `NEXT_PUBLIC_SUPABASE_ANON_KEY`: the anon/public key - `SUPABASE_SERVICE_ROLE_KEY`: the service_role key (used by the cron job) +- `NEXT_PUBLIC_SITE_URL`: the canonical origin of the deployed app (e.g. `https://productbuilders.app`). Used by the auth callback to build the post-login redirect host. **Required in production.** The callback deliberately does not trust `x-forwarded-host`. - `CRON_SECRET`: generate with `openssl rand -base64 32` The service role key and cron secret are server-only. Never commit them or expose them to the browser. @@ -138,6 +139,7 @@ In the Cloudflare dashboard for the Pages project, set: - `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `NEXT_PUBLIC_SITE_URL` (e.g. `https://productbuilders.app`) — **required in production** so the auth callback can build a trusted post-login redirect host - `SUPABASE_SERVICE_ROLE_KEY` (used by `/api/cron/demo-day`) - `CRON_SECRET` (used by `/api/cron/demo-day`) diff --git a/docs/AUTH_DEBUGGING_HANDOFF.md b/docs/AUTH_DEBUGGING_HANDOFF.md index 512fd2e..1354979 100644 --- a/docs/AUTH_DEBUGGING_HANDOFF.md +++ b/docs/AUTH_DEBUGGING_HANDOFF.md @@ -108,6 +108,38 @@ The previous code did not log the Supabase code-exchange failure mode at all on **Fix:** consolidated to a single error log that captures `error.message` and `error.status` only — no tokens, codes, cookies, or env values. The bare-redirect-on-error path is gone (the explicit `return` covers it now). +### 4. Auth callback trusted `x-forwarded-host` blindly (added on `fix/auth-end-to-end-product`) + +The original production branch read `x-forwarded-host` to build the post-login redirect host. This is spoofable: an attacker can set `x-forwarded-host: evil.com` on a forged request and have the callback redirect a freshly-authenticated user to `https://evil.com/...`. Even though session cookies are set first, the redirect target itself is attacker-controlled. + +**Fix:** added `getSafeRedirectOrigin(request)` in `src/app/auth/callback/route.ts`. It picks the redirect origin from a strict priority order: + +1. `NEXT_PUBLIC_SITE_URL` — owner-configured, validated as `http(s):` URL. +2. Request origin in development. +3. Request origin in production (a sane fallback; the request URL on Cloudflare Pages is the external URL the browser sent). + +`x-forwarded-host` is **never** consulted. If `NEXT_PUBLIC_SITE_URL` is unset in production, the callback logs a one-shot warning and falls back to the request origin. Owner must set `NEXT_PUBLIC_SITE_URL` per environment to make the redirect host deterministic and not request-shape-dependent. + +The path part is still validated by `safeRedirectPath()` so the destination cannot be `//evil.com`, `https://evil.com`, etc. + +### 4. Auth callback trusted `x-forwarded-host` blindly (added on `fix/auth-end-to-end-product`) + +The original production branch read `x-forwarded-host` to build the post-login redirect host. This is spoofable: an attacker can set `x-forwarded-host: evil.com` on a forged request and have the callback redirect a freshly-authenticated user to `https://evil.com/...`. Even though session cookies are set first, the redirect target itself is attacker-controlled. + +**Fix:** added `getSafeRedirectOrigin(request)` in `src/app/auth/callback/route.ts`. It picks the redirect origin from a strict priority order: + +1. `NEXT_PUBLIC_SITE_URL` — owner-configured, validated as `http(s):` URL. +2. Request origin in development. +3. Request origin in production (a sane fallback; the request URL on Cloudflare Pages is the external URL the browser sent). + +`x-forwarded-host` is **never** consulted. If `NEXT_PUBLIC_SITE_URL` is unset in production, the callback logs a one-shot warning and falls back to the request origin. Owner must set `NEXT_PUBLIC_SITE_URL` per environment to make the redirect host deterministic and not request-shape-dependent. + +The path part is still validated by `safeRedirectPath()` so the destination cannot be `//evil.com`, `https://evil.com`, etc. + +The previous code did not log the Supabase code-exchange failure mode at all on the second error path (the "no session returned" fallback). The first error path was correct. This made it impossible to distinguish "Google didn't send a code" from "Supabase rejected the code" from "exchange succeeded but no session" without staring at network traces. + +**Fix:** consolidated to a single error log that captures `error.message` and `error.status` only — no tokens, codes, cookies, or env values. The bare-redirect-on-error path is gone (the explicit `return` covers it now). + ### What was NOT broken - The PKCE flow itself — `signInWithOtp` and `signInWithOAuth` both succeed at the API level when invoked with the exact login-page options (`hasError: false`, valid response). @@ -124,9 +156,9 @@ The previous code did not log the Supabase code-exchange failure mode at all on |---|---| | `src/lib/supabase/middleware.ts` | `redirectWithCookies` helper; both redirect sites use it. | | `src/app/onboarding/actions.ts` | `update` → `upsert` with `onConflict: "id"`. | -| `src/app/auth/callback/route.ts` | Consolidated error logging — `error.message` + `error.status` only, no tokens/cookies/codes. Removed redundant second error path. | +| `src/app/auth/callback/route.ts` | Consolidated error logging — `error.message` + `error.status` only, no tokens/cookies/codes. Removed redundant second error path. Host selection hardened via `getSafeRedirectOrigin` (uses `NEXT_PUBLIC_SITE_URL`, never trusts `x-forwarded-host`). | | `docs/AUTH_DEBUGGING_HANDOFF.md` | This document. | -| `scripts/auth-smoke.sh` | Read-only smoke test: route status, protected-route redirects, callback redirects. | +| `scripts/auth-smoke.sh` | Read-only smoke test: route status, protected-route redirects, callback redirects. BASE_URL-safe — works against localhost, production, and Cloudflare preview URLs. | --- @@ -141,7 +173,7 @@ The previous code did not log the Supabase code-exchange failure mode at all on 5. Supabase validates the Google auth code, then 302s the browser to the app's `redirectTo` with `?code=...`. 6. Browser hits `/auth/callback?code=...`. 7. Callback calls `exchangeCodeForSession(code)`. PKCE verifier is in the cookies the browser sent. -8. On success, callback sets the Supabase auth cookies on the redirect response, then redirects to `redirect` (or `/onboarding` if no `profile.handle`). +8. On success, callback picks a safe redirect origin via `getSafeRedirectOrigin(request)` — `NEXT_PUBLIC_SITE_URL` if set, else request origin in dev, else request origin in prod. It does **not** read `x-forwarded-host`. It then sets the Supabase auth cookies on the redirect response and redirects to `` (where `redirectPath` is the `redirect` query, or `/onboarding` if no `profile.handle`). 9. Proxy on the next request sees the session, sees no handle → redirects to `/onboarding` (still carrying the cookies). 10. Onboarding form submits → `upsert` writes the handle → `router.push("/")`. 11. Proxy on `/` sees the session and the handle → passes through. @@ -166,6 +198,8 @@ npm --version cp .env.example .env.local # Fill in NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY from # your Supabase project (Project Settings → API). +# NEXT_PUBLIC_SITE_URL is optional in dev (request origin is used), but +# must be set in production (e.g. https://productbuilders.app). # SUPABASE_SERVICE_ROLE_KEY and CRON_SECRET are only needed for the cron. ``` @@ -307,11 +341,14 @@ Set in **Cloudflare Pages → Settings → Environment variables**, per environm |---|---|---| | `NEXT_PUBLIC_SUPABASE_URL` | Public | injected at build time | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Public | injected at build time | +| `NEXT_PUBLIC_SITE_URL` | Public | e.g. `https://productbuilders.app`. **Required in production.** Used by the auth callback to build the post-login redirect host. The callback deliberately does not trust `x-forwarded-host`. | | `SUPABASE_SERVICE_ROLE_KEY` | Secret | only the cron handler reads it | | `CRON_SECRET` | Secret | bearer token for `/api/cron/demo-day` | Never commit any of these. Use `wrangler secret put ` (Secrets) or the Cloudflare dashboard; the public vars go under **Variables** with no type / "Plaintext". +For preview environments, set `NEXT_PUBLIC_SITE_URL` to the preview URL (e.g. `https://fix-auth-end-to-end-product..pages.dev`). + ### K. Local variables for Cloudflare dev (`wrangler dev` / preview) `.dev.vars` (gitignored) holds Secrets for local `wrangler dev`. The repo's `.gitignore` does not currently list `.dev.vars` — Owner must add it before adding one, or use a local-only filename. @@ -359,7 +396,12 @@ For the cron, click "Take snapshot now" in `/admin` (requires admin role on `pro 2. **Dashboard config drift.** If any of A–H above is wrong on the deployed project, real-user sign-in will fail even though the code is correct. The owner must verify. 3. **Migrations 004–006 from draft PR #53** are not on this branch. If the production DB still lacks `demo_type`, `problem`, `audience` columns, the submit form's `safePayload` fallback will silently drop them. The proper fix is to apply migrations 002 (and any of 004–006 needed). The owner must decide. 4. **The `handle_new_user` trigger may or may not be present** in the live DB. The onboarding `upsert` covers the gap, but if it's missing, the navbar's first `getUser` call after sign-up will see a missing profile row (it's handled with the `data ?? { ...defaults }` pattern, so the UI doesn't crash, but the avatar/handle will be empty until onboarding completes). -5. **`x-forwarded-host` host sniffing.** The callback uses `x-forwarded-host` for the host only when `NODE_ENV !== "development"`. Cloudflare sets `x-forwarded-host` to the original request host; the callback uses `.split(",")[0].trim()`. If the production host changes (custom domain swap, Pages preview domain), verify the redirect target matches. +5. **`x-forwarded-host` host sniffing (resolved on this branch).** The callback previously trusted `x-forwarded-host` in production, which is spoofable. Now the callback picks the post-login redirect host from a strict priority order: + 1. `NEXT_PUBLIC_SITE_URL` (owner-configured; treated as authoritative if present and a valid `http(s):` URL). + 2. Request origin in development. + 3. Request origin in production (a sane fallback when `NEXT_PUBLIC_SITE_URL` is unset — the request URL on Cloudflare Pages is the external URL the browser sent). + + `x-forwarded-host` is **never** consulted. If `NEXT_PUBLIC_SITE_URL` is unset in production, the callback logs a one-shot warning at module load and falls back to the request origin. Owner must set `NEXT_PUBLIC_SITE_URL` per environment. 6. **No automated tests.** The repo has no `npm test` script. The smoke script is bash + curl; it doesn't exercise the React components or the upsert behavior. Future work: add a Playwright suite for the auth flow. --- diff --git a/scripts/auth-smoke.sh b/scripts/auth-smoke.sh index c702cfd..76c0d7a 100755 --- a/scripts/auth-smoke.sh +++ b/scripts/auth-smoke.sh @@ -12,8 +12,10 @@ # - use the service role key # # Usage: -# ./scripts/auth-smoke.sh # against http://localhost:3000 +# ./scripts/auth-smoke.sh # http://localhost:3000 +# BASE_URL=http://localhost:3000 ./scripts/auth-smoke.sh # BASE_URL=https://productbuilders.app ./scripts/auth-smoke.sh +# BASE_URL=https://fix-auth..pages.dev ./scripts/auth-smoke.sh set -u @@ -24,6 +26,7 @@ PASSES=0 # --- helpers --------------------------------------------------------------- # Probe a URL, capture status code + Location header. No body. +# Output format: "status|location" (location is empty if no redirect header) probe() { local url="$1" local label="$2" @@ -32,11 +35,45 @@ probe() { local status status=$(printf '%s' "$headers" | tail -n1) local location - location=$(printf '%s' "$headers" | grep -i '^location:' | head -n1 | sed 's/^[Ll]ocation:[[:space:]]*//' | tr -d '\r\n') + location=$(printf '%s' "$headers" \ + | grep -i '^location:' \ + | head -n1 \ + | sed 's/^[Ll]ocation:[[:space:]]*//' \ + | tr -d '\r\n') echo " $label → status=$status location=${location:-}" echo "$status|$location" } +# Normalize a Location header to a path+query string. +# Handles: +# /foo?bar=1 → /foo?bar=1 +# http://host:3000/foo?bar=1 → /foo?bar=1 +# https://host/foo → /foo +# https://host → / +# https://host/ → / +# Empty input → empty output. +normalize_location() { + local loc="$1" + if [[ -z "$loc" ]]; then + echo "" + return + fi + if [[ "$loc" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then + # Absolute URL — strip scheme + authority, prepend "/" if not already. + local rest + rest=$(printf '%s' "$loc" | sed -E 's|^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*||') + if [[ -z "$rest" ]]; then + echo "/" + elif [[ "$rest" == /* ]]; then + echo "$rest" + else + echo "/$rest" + fi + else + echo "$loc" + fi +} + # Assert "actual" matches "expected". Prints PASS / FAIL. expect() { local label="$1" @@ -51,6 +88,7 @@ expect() { fi } +# Assert "actual" starts with "prefix". expect_prefix() { local label="$1" local actual="$2" @@ -64,6 +102,23 @@ expect_prefix() { fi } +# Probe a URL, normalize the Location header, and split into status + path. +# Output: status|path|rawlocation (path is "" if no redirect; rawlocation is +# the verbatim Location header for debugging). +probe_path() { + local url="$1" + local label="$2" + local result + result=$(probe "$url" "$label") + local status + status=$(echo "$result" | tail -n1 | cut -d'|' -f1) + local raw_loc + raw_loc=$(echo "$result" | tail -n1 | cut -d'|' -f2) + local path + path=$(normalize_location "$raw_loc") + echo "$status|$path|$raw_loc" +} + # --- server reachable? ----------------------------------------------------- echo "== auth smoke test against $BASE_URL ==" @@ -71,7 +126,7 @@ echo "== auth smoke test against $BASE_URL ==" ROOT_RESULT=$(probe "$BASE_URL/" "GET /") ROOT_STATUS=$(echo "$ROOT_RESULT" | tail -n1 | cut -d'|' -f1) if [[ "$ROOT_STATUS" != "200" ]]; then - echo " Dev server not reachable at $BASE_URL — start it with 'npm run dev'." + echo " Server not reachable at $BASE_URL — start it (npm run dev) or check the URL." exit 2 fi @@ -93,44 +148,44 @@ expect "GET /demo-days status" "$(echo "$DD_RESULT" | tail -n1 | cut -d'|' -f1)" # --- protected routes (anonymous → redirect to /login) -------------------- echo "[protected routes — anonymous]" -SUB_RESULT=$(probe "$BASE_URL/submit" "GET /submit") -SUB_STATUS=$(echo "$SUB_RESULT" | tail -n1 | cut -d'|' -f1) -SUB_LOC=$(echo "$SUB_RESULT" | tail -n1 | cut -d'|' -f2) +SUB=$(probe_path "$BASE_URL/submit" "GET /submit") +SUB_STATUS=$(echo "$SUB" | cut -d'|' -f1) +SUB_PATH=$(echo "$SUB" | cut -d'|' -f2) expect "GET /submit status" "$SUB_STATUS" "307" -expect_prefix "GET /submit location" "${SUB_LOC##*localhost:3000}" "/login?redirect=" +expect_prefix "GET /submit location" "$SUB_PATH" "/login?redirect=" -SET_RESULT=$(probe "$BASE_URL/settings" "GET /settings") -SET_STATUS=$(echo "$SET_RESULT" | tail -n1 | cut -d'|' -f1) -SET_LOC=$(echo "$SET_RESULT" | tail -n1 | cut -d'|' -f2) +SET=$(probe_path "$BASE_URL/settings" "GET /settings") +SET_STATUS=$(echo "$SET" | cut -d'|' -f1) +SET_PATH=$(echo "$SET" | cut -d'|' -f2) expect "GET /settings status" "$SET_STATUS" "307" -expect_prefix "GET /settings location" "${SET_LOC##*localhost:3000}" "/login?redirect=" +expect_prefix "GET /settings location" "$SET_PATH" "/login?redirect=" -ADM_RESULT=$(probe "$BASE_URL/admin" "GET /admin") -ADM_STATUS=$(echo "$ADM_RESULT" | tail -n1 | cut -d'|' -f1) -ADM_LOC=$(echo "$ADM_RESULT" | tail -n1 | cut -d'|' -f2) +ADM=$(probe_path "$BASE_URL/admin" "GET /admin") +ADM_STATUS=$(echo "$ADM" | cut -d'|' -f1) +ADM_PATH=$(echo "$ADM" | cut -d'|' -f2) expect "GET /admin status" "$ADM_STATUS" "307" -expect_prefix "GET /admin location" "${ADM_LOC##*localhost:3000}" "/login?redirect=" +expect_prefix "GET /admin location" "$ADM_PATH" "/login?redirect=" # --- auth callback error paths -------------------------------------------- echo "[auth callback — error paths]" -CB_NONE=$(probe "$BASE_URL/auth/callback" "GET /auth/callback (no code)") -CB_NONE_STATUS=$(echo "$CB_NONE" | tail -n1 | cut -d'|' -f1) -CB_NONE_LOC=$(echo "$CB_NONE" | tail -n1 | cut -d'|' -f2) +CB_NONE=$(probe_path "$BASE_URL/auth/callback" "GET /auth/callback (no code)") +CB_NONE_STATUS=$(echo "$CB_NONE" | cut -d'|' -f1) +CB_NONE_PATH=$(echo "$CB_NONE" | cut -d'|' -f2) expect "GET /auth/callback status" "$CB_NONE_STATUS" "307" -expect_prefix "GET /auth/callback location" "${CB_NONE_LOC##*localhost:3000}" "/login?error=" +expect_prefix "GET /auth/callback location" "$CB_NONE_PATH" "/login?error=" -CB_EMPTY=$(probe "$BASE_URL/auth/callback?code=&redirect=/" "GET /auth/callback (empty code)") -CB_EMPTY_STATUS=$(echo "$CB_EMPTY" | tail -n1 | cut -d'|' -f1) -CB_EMPTY_LOC=$(echo "$CB_EMPTY" | tail -n1 | cut -d'|' -f2) +CB_EMPTY=$(probe_path "$BASE_URL/auth/callback?code=&redirect=/" "GET /auth/callback (empty code)") +CB_EMPTY_STATUS=$(echo "$CB_EMPTY" | cut -d'|' -f1) +CB_EMPTY_PATH=$(echo "$CB_EMPTY" | cut -d'|' -f2) expect "GET /auth/callback (empty) status" "$CB_EMPTY_STATUS" "307" -expect_prefix "GET /auth/callback (empty) location" "${CB_EMPTY_LOC##*localhost:3000}" "/login?error=" +expect_prefix "GET /auth/callback (empty) location" "$CB_EMPTY_PATH" "/login?error=" -CB_DENIED=$(probe "$BASE_URL/auth/callback?error=access_denied&error_description=User+denied" "GET /auth/callback (provider denied)") -CB_DENIED_STATUS=$(echo "$CB_DENIED" | tail -n1 | cut -d'|' -f1) -CB_DENIED_LOC=$(echo "$CB_DENIED" | tail -n1 | cut -d'|' -f2) +CB_DENIED=$(probe_path "$BASE_URL/auth/callback?error=access_denied&error_description=User+denied" "GET /auth/callback (provider denied)") +CB_DENIED_STATUS=$(echo "$CB_DENIED" | cut -d'|' -f1) +CB_DENIED_PATH=$(echo "$CB_DENIED" | cut -d'|' -f2) expect "GET /auth/callback (denied) status" "$CB_DENIED_STATUS" "307" -expect "GET /auth/callback (denied) location suffix" "${CB_DENIED_LOC##*localhost:3000}" "/login?error=access_denied" +expect "GET /auth/callback (denied) location" "$CB_DENIED_PATH" "/login?error=access_denied" # --- summary --------------------------------------------------------------- diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 0a510d3..1971293 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -4,6 +4,61 @@ import type { CookieOptions } from "@supabase/ssr"; import { safeRedirectPath } from "@/lib/safe-redirect"; import { getSupabaseEnv } from "@/lib/supabase/env"; +/** + * Pick a trusted origin for the post-login redirect. + * + * Priority order: + * 1. NEXT_PUBLIC_SITE_URL (owner-configured, treated as authoritative). + * 2. Request origin in development (always http://localhost:). + * 3. Request origin in production (a sane fallback; the request URL on + * Cloudflare Pages is the external URL the browser sent). + * + * We deliberately do NOT trust x-forwarded-host. Cloudflare sets it to the + * external host, but attackers can also set it on forged requests. The only + * safe source of the production host is a value the owner pinned in env, + * which is why NEXT_PUBLIC_SITE_URL takes priority. + */ +function getSafeRedirectOrigin(request: Request): string { + const requestOrigin = new URL(request.url).origin; + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (siteUrl) { + try { + const u = new URL(siteUrl); + if (u.protocol === "http:" || u.protocol === "https:") { + return u.origin; + } + } catch { + // Invalid NEXT_PUBLIC_SITE_URL — fall through. + } + } + + if (process.env.NODE_ENV === "development") { + return requestOrigin; + } + + return requestOrigin; +} + +/** + * One-shot warning if NEXT_PUBLIC_SITE_URL is missing in production. Owner + * should set this to e.g. https://productbuilders.app so the post-login + * redirect host is deterministic and not request-shape-dependent. + */ +let warnedMissingSiteUrl = false; +function warnMissingSiteUrlOnce() { + if (warnedMissingSiteUrl) return; + if (process.env.NODE_ENV === "development") return; + if (process.env.NEXT_PUBLIC_SITE_URL) return; + console.warn( + "[auth/callback] NEXT_PUBLIC_SITE_URL is not set in production. " + + "Falling back to the request origin for the post-login redirect host. " + + "Set NEXT_PUBLIC_SITE_URL to a stable production origin (e.g. https://productbuilders.app) " + + "to avoid surprises behind proxies." + ); + warnedMissingSiteUrl = true; +} + export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); @@ -72,12 +127,9 @@ export async function GET(request: Request) { } } - const forwardedHost = request.headers.get("x-forwarded-host"); - const host = - forwardedHost && process.env.NODE_ENV !== "development" - ? `https://${forwardedHost.split(",")[0].trim()}` - : origin; - const redirectTo = `${host}${redirectPath}`; + warnMissingSiteUrlOnce(); + const safeOrigin = getSafeRedirectOrigin(request); + const redirectTo = `${safeOrigin}${redirectPath}`; const response = NextResponse.redirect(redirectTo); for (const { name, value, options } of collectedCookies) { @@ -89,4 +141,4 @@ export async function GET(request: Request) { } return NextResponse.redirect(`${origin}/login?error=auth`); -} +} \ No newline at end of file