From 5630f9b8e839441006585640beabe2f3b7ecfbdb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 9 Feb 2026 01:06:33 +0530 Subject: [PATCH] feat: add sign-out redirect URL and enhance sign-in redirect handling --- frontend/.env.example | 1 + frontend/src/app/sign-in/[[...rest]]/page.tsx | 14 +++++- frontend/src/auth/redirects.test.ts | 44 +++++++++++++++++++ frontend/src/auth/redirects.ts | 31 +++++++++++++ .../src/components/providers/AuthProvider.tsx | 9 +++- frontend/src/proxy.ts | 30 +++++++++++-- 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 frontend/src/auth/redirects.test.ts create mode 100644 frontend/src/auth/redirects.ts diff --git a/frontend/.env.example b/frontend/.env.example index 9c4a3f7a..f8e1716d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -8,3 +8,4 @@ NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards +NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/ diff --git a/frontend/src/app/sign-in/[[...rest]]/page.tsx b/frontend/src/app/sign-in/[[...rest]]/page.tsx index bf55b4bb..196d303a 100644 --- a/frontend/src/app/sign-in/[[...rest]]/page.tsx +++ b/frontend/src/app/sign-in/[[...rest]]/page.tsx @@ -1,13 +1,25 @@ "use client"; +import { useSearchParams } from "next/navigation"; import { SignIn } from "@clerk/nextjs"; +import { resolveSignInRedirectUrl } from "@/auth/redirects"; + export default function SignInPage() { + const searchParams = useSearchParams(); + const forceRedirectUrl = resolveSignInRedirectUrl( + searchParams.get("redirect_url"), + ); + // Dedicated sign-in route for Cypress E2E. // Avoids modal/iframe auth flows and gives Cypress a stable top-level page. return (
- +
); } diff --git a/frontend/src/auth/redirects.test.ts b/frontend/src/auth/redirects.test.ts new file mode 100644 index 00000000..fa5396ac --- /dev/null +++ b/frontend/src/auth/redirects.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveSignInRedirectUrl } from "@/auth/redirects"; + +describe("resolveSignInRedirectUrl", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses env fallback when redirect is missing", () => { + vi.stubEnv("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL", "/boards"); + + expect(resolveSignInRedirectUrl(null)).toBe("/boards"); + }); + + it("defaults to /dashboard when no env fallback is set", () => { + expect(resolveSignInRedirectUrl(null)).toBe("/dashboard"); + }); + + it("allows safe relative paths", () => { + expect(resolveSignInRedirectUrl("/dashboard?tab=ops#queue")).toBe( + "/dashboard?tab=ops#queue", + ); + }); + + it("rejects protocol-relative urls", () => { + vi.stubEnv("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL", "/activity"); + + expect(resolveSignInRedirectUrl("//evil.example.com/path")).toBe("/activity"); + }); + + it("rejects external absolute urls", () => { + vi.stubEnv("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL", "/activity"); + + expect(resolveSignInRedirectUrl("https://evil.example.com/steal")).toBe( + "/activity", + ); + }); + + it("accepts same-origin absolute urls and normalizes to path", () => { + const url = `${window.location.origin}/boards/new?src=invite#top`; + expect(resolveSignInRedirectUrl(url)).toBe("/boards/new?src=invite#top"); + }); +}); diff --git a/frontend/src/auth/redirects.ts b/frontend/src/auth/redirects.ts new file mode 100644 index 00000000..aa27617f --- /dev/null +++ b/frontend/src/auth/redirects.ts @@ -0,0 +1,31 @@ +const DEFAULT_SIGN_IN_REDIRECT = "/dashboard"; + +function isSafeRelativePath(value: string): boolean { + return value.startsWith("/") && !value.startsWith("//"); +} + +export function resolveSignInRedirectUrl(rawRedirect: string | null): string { + const fallback = + process.env.NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL ?? + DEFAULT_SIGN_IN_REDIRECT; + + if (!rawRedirect) return fallback; + + if (isSafeRelativePath(rawRedirect)) { + return rawRedirect; + } + + if (typeof window === "undefined") { + return fallback; + } + + try { + const parsed = new URL(rawRedirect, window.location.origin); + if (parsed.origin !== window.location.origin) { + return fallback; + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return fallback; + } +} diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index 91309281..bbe44ffb 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -7,12 +7,19 @@ import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; export function AuthProvider({ children }: { children: ReactNode }) { const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + const afterSignOutUrl = + process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/"; if (!isLikelyValidClerkPublishableKey(publishableKey)) { return <>{children}; } return ( - {children} + + {children} + ); } diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 6f1dfb2b..55604bb3 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -8,18 +8,40 @@ const isClerkEnabled = () => process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); -// Public routes must include Clerk sign-in paths to avoid redirect loops. -const isPublicRoute = createRouteMatcher(["/sign-in(.*)"]); +// Public routes include home and sign-in paths to avoid redirect loops. +const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)"]); + +function isClerkInternalPath(pathname: string): boolean { + // Clerk may hit these paths for internal auth/session refresh flows. + return pathname.startsWith("/_clerk") || pathname.startsWith("/v1/"); +} + +function requestOrigin(req: Request): string { + const forwardedProto = req.headers.get("x-forwarded-proto"); + const forwardedHost = req.headers.get("x-forwarded-host"); + const host = forwardedHost ?? req.headers.get("host"); + const proto = forwardedProto ?? "http"; + if (host) return `${proto}://${host}`; + return new URL(req.url).origin; +} + +function returnBackUrlFor(req: Request): string { + const { pathname, search, hash } = new URL(req.url); + return `${requestOrigin(req)}${pathname}${search}${hash}`; +} export default isClerkEnabled() ? clerkMiddleware(async (auth, req) => { + if (isClerkInternalPath(new URL(req.url).pathname)) { + return NextResponse.next(); + } if (isPublicRoute(req)) return NextResponse.next(); // In middleware, `auth()` resolves to a session/auth context (Promise in current typings). // Use redirectToSignIn() (instead of protect()) for unauthenticated requests. const { userId, redirectToSignIn } = await auth(); if (!userId) { - return redirectToSignIn({ returnBackUrl: req.url }); + return redirectToSignIn({ returnBackUrl: returnBackUrlFor(req) }); } return NextResponse.next(); @@ -28,7 +50,7 @@ export default isClerkEnabled() export const config = { matcher: [ - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/((?!_next|_clerk|v1|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], };