/** * Auth callback. The link emailed by Supabase / GoTrue can arrive in one of * two shapes depending on flow: * * 1. OAuth code exchange: /auth/callback?code=<>&next=/... * 2. PKCE email confirm: /auth/callback?token=<>&type=signup|magiclink|recovery|invite&next=/... * (also: token_hash variant on newer Supabase JS) * * We handle both, exchange for a session, and bounce to `next` (or /). */ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; type OtpType = "signup" | "magiclink" | "recovery" | "invite" | "email_change"; const VALID_OTP_TYPES: Set = new Set([ "signup", "magiclink", "recovery", "invite", "email_change", ]); export async function GET(request: Request) { const url = new URL(request.url); const { searchParams, origin } = url; // Always bounce to the public site origin, not whatever Host the // request happened to come in on (e.g. api.disclosure.top via the Kong // proxy fallback). NEXT_PUBLIC_SITE_URL is baked at build time. const siteOrigin = process.env.NEXT_PUBLIC_SITE_URL || origin; // CRITICAL: cookies are scoped to the request host. If the link in the // email landed on the API host (api.disclosure.top, proxied via Kong), // any session cookie we set here would be on api.* and the user would // appear logged out when they bounce to disclosure.top. Redirect first // so verifyOtp runs on the site domain. // // `url.host` returns the internal listen host inside the Next.js server // (typically `localhost:3000`), NOT the public host the browser used. // We must read the proxy-forwarded host header to know where the user // actually is. Traefik sets X-Forwarded-Host; Kong preserves Host. const forwardedHost = (request.headers.get("x-forwarded-host") || request.headers.get("host") || "").toLowerCase(); try { const siteUrl = new URL(siteOrigin); if (forwardedHost && forwardedHost !== siteUrl.host.toLowerCase()) { const redirected = new URL("/auth/callback", siteOrigin); searchParams.forEach((v, k) => redirected.searchParams.set(k, v)); return NextResponse.redirect(redirected.toString()); } } catch { /* siteOrigin malformed — fall through and try local verify */ } const code = searchParams.get("code"); const tokenHash = searchParams.get("token_hash") || searchParams.get("token"); const typeRaw = searchParams.get("type"); const next = searchParams.get("next") ?? "/"; const supabase = await createClient(); // Path 1 — PKCE / OTP token (token + type) if (tokenHash && typeRaw && VALID_OTP_TYPES.has(typeRaw as OtpType)) { const { error } = await supabase.auth.verifyOtp({ type: typeRaw as OtpType, token_hash: tokenHash, }); if (error) { return NextResponse.redirect( `${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`, ); } return NextResponse.redirect(`${siteOrigin}${next}`); } // Path 2 — OAuth code exchange if (code) { const { error } = await supabase.auth.exchangeCodeForSession(code); if (error) { return NextResponse.redirect( `${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`, ); } return NextResponse.redirect(`${siteOrigin}${next}`); } // Neither shape provided return NextResponse.redirect(`${siteOrigin}/auth/signin?error=missing_token`); }