diff --git a/src/app/auth/confirm/route.test.ts b/src/app/auth/confirm/route.test.ts new file mode 100644 index 00000000..e13231d9 --- /dev/null +++ b/src/app/auth/confirm/route.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveMagicLinkRedirect } from "./route"; + +describe("resolveMagicLinkRedirect", () => { + const appUrl = "https://ugig.net"; + + it("preserves internal paths and query parameters", () => { + expect(resolveMagicLinkRedirect(appUrl, "/dashboard?tab=applications")).toBe( + "https://ugig.net/dashboard?tab=applications" + ); + }); + + it.each([ + ["host-like value", "@example.com"], + ["absolute URL", "https://example.com"], + ["protocol-relative URL", "//example.com"], + ["leading backslash", "\\example.com"], + ["mixed slash URL", "/\\example.com"], + ["empty string", ""], + ])("falls back for %s", (_label, next) => { + expect(resolveMagicLinkRedirect(appUrl, next)).toBe("https://ugig.net/dashboard"); + }); + + it("falls back when next is missing", () => { + expect(resolveMagicLinkRedirect(appUrl, null)).toBe("https://ugig.net/dashboard"); + }); +}); diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts index e0497abf..57ed6fd7 100644 --- a/src/app/auth/confirm/route.ts +++ b/src/app/auth/confirm/route.ts @@ -14,10 +14,31 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +export function resolveMagicLinkRedirect(appUrl: string, next: string | null): string { + const fallback = new URL("/dashboard", appUrl); + + if (!next?.startsWith("/")) { + return fallback.toString(); + } + + try { + const target = new URL(next, fallback.origin); + return target.origin === fallback.origin ? target.toString() : fallback.toString(); + } catch { + return fallback.toString(); + } +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const tokenHash = searchParams.get("token_hash"); - const type = searchParams.get("type") as "signup" | "email" | "recovery" | "invite" | "magiclink" | null; + const type = searchParams.get("type") as + | "signup" + | "email" + | "recovery" + | "invite" + | "magiclink" + | null; const next = searchParams.get("next"); const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; @@ -48,8 +69,8 @@ export async function GET(request: NextRequest) { } // Magic link (OAuth flow) — go to next or dashboard - if (type === "magiclink" && next) { - return NextResponse.redirect(`${appUrl}${next}`); + if (type === "magiclink") { + return NextResponse.redirect(resolveMagicLinkRedirect(appUrl, next)); } // Signup/email confirmation — go to login with success message