From b6b390b6baed4f356938ebc189cd6ac31a7afa5c Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:37:27 -0500 Subject: [PATCH 1/2] fix(auth): preserve intended URL through login redirects --- .../shareChatPopover/shareSettings.tsx | 7 +++- packages/web/src/app/(app)/layout.tsx | 14 ++++--- .../src/app/components/authMethodSelector.tsx | 12 +++--- .../src/app/login/components/loginForm.tsx | 16 +++----- .../app/login/components/magicLinkForm.tsx | 8 ++-- packages/web/src/app/login/page.tsx | 6 ++- packages/web/src/app/login/verify/page.tsx | 13 ++++++- .../web/src/app/login/verify/verifyForm.tsx | 5 ++- packages/web/src/app/signup/page.tsx | 6 ++- .../chatThread/signInPromptBanner.tsx | 7 +++- .../chat/mcp/components/connectorsMenu.tsx | 6 ++- .../chat/components/chatBox/loginDialog.tsx | 6 ++- packages/web/src/lib/authRedirect.test.ts | 27 ++++++++++++++ packages/web/src/lib/authRedirect.ts | 37 +++++++++++++++++++ .../web/src/middleware/authenticatedPage.tsx | 8 +++- packages/web/src/proxy.ts | 10 ++++- 16 files changed, 146 insertions(+), 42 deletions(-) create mode 100644 packages/web/src/lib/authRedirect.test.ts create mode 100644 packages/web/src/lib/authRedirect.ts diff --git a/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx b/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx index 9dd0a6668..9bccbc315 100644 --- a/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx +++ b/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx @@ -17,7 +17,8 @@ import { ChatVisibility } from "@sourcebot/db"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Info, Link2Icon, Loader2, Lock, X } from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; +import { createLoginUrl } from "@/lib/authRedirect"; import { useCallback, useState } from "react"; import { captureEvent } from "@/hooks/useCaptureEvent"; @@ -48,6 +49,8 @@ export const ShareSettings = ({ const [removingUserIds, setRemovingUserIds] = useState>(new Set()); const { toast } = useToast(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`); const isAuthenticated = !!currentUser; const handleCopyLink = useCallback(async () => { @@ -217,7 +220,7 @@ export const ShareSettings = ({ {!isAuthenticated && (

- Sign in to change chat visibility. + Sign in to change chat visibility.

)} diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 435678ef5..fa0d3762e 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -38,6 +38,7 @@ import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server"; import { NavigationGuardProvider } from "next-navigation-guard"; import { getRepositorySyncCounts } from "@/features/repos/repositorySyncCounts.server"; import { getConnectionSyncCounts } from "@/features/connections/connectionSyncCounts.server"; +import { createLoginUrl, normalizeCallbackUrl, REQUEST_PATH_HEADER } from "@/lib/authRedirect"; interface LayoutProps { children: React.ReactNode; @@ -60,6 +61,7 @@ export default async function Layout(props: LayoutProps) { const session = await auth(); const anonymousAccessEnabled = await isAnonymousAccessEnabled(); + const requestHeaders = await headers(); let role: OrgRole | null = null; @@ -111,9 +113,9 @@ export default async function Layout(props: LayoutProps) { if (!anonymousAccessEnabled) { const ssoEntitlement = await hasEntitlement("sso"); if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { - return ; + return ; } else { - redirect('/login'); + redirect(createLoginUrl(requestHeaders.get(REQUEST_PATH_HEADER))); } } } @@ -144,16 +146,18 @@ export default async function Layout(props: LayoutProps) { return (
- +
) } } } - const headersList = await headers(); const cookieStore = await cookies() - const userAgent = headersList.get('user-agent'); + const userAgent = requestHeaders.get('user-agent'); const { isMobile } = userAgent ? getSelectorsByUserAgent(userAgent) : { isMobile: false }; if (isMobile && !cookieStore.has(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME)) { diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index d285b8ca9..de2b37b29 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -10,6 +10,7 @@ import { ProviderButton } from "@/app/components/providerButton"; import { AuthSecurityNotice } from "@/app/components/authSecurityNotice"; import Link from "next/link"; import { useIdentityProviders } from "@/features/auth/useIdentityProviders"; +import { normalizeCallbackUrl } from "@/lib/authRedirect"; interface AuthMethodSelectorProps { callbackUrl?: string; @@ -27,6 +28,7 @@ export const AuthMethodSelector = ({ hideSecurityNotice = false }: AuthMethodSelectorProps) => { const providers = useIdentityProviders(); + const safeCallbackUrl = normalizeCallbackUrl(callbackUrl); const onSignInWithOauth = useCallback((provider: string) => { // Call the optional analytics callback first @@ -35,10 +37,10 @@ export const AuthMethodSelector = ({ signIn( provider, { - redirectTo: callbackUrl ?? "/", + redirectTo: safeCallbackUrl, } ); - }, [callbackUrl, onProviderClick]); + }, [onProviderClick, safeCallbackUrl]); // Separate OAuth providers from special auth methods const oauthProviders = providers.filter(p => p.purpose === "sso" && @@ -80,13 +82,13 @@ export const AuthMethodSelector = ({ ] : []), ...(hasMagicLink ? [ - + ] : []), ...(hasCredentials ? [ - + ] : []) ]} /> ); -}; \ No newline at end of file +}; diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index 6274ab34e..a29910f7f 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -7,6 +7,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useIdentityProviders } from "@/features/auth/useIdentityProviders"; import Link from "next/link"; +import { normalizeCallbackUrl } from "@/lib/authRedirect"; interface LoginFormProps { callbackUrl?: string; @@ -20,14 +21,7 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable const captureEvent = useCaptureEvent(); const providers = useIdentityProviders(); - const safeCallbackUrl = useMemo(() => { - if (!callbackUrl) return "/"; - // Allow only relative paths that start with "/" but not "//" (protocol-relative URLs) - if (callbackUrl.startsWith("/") && !callbackUrl.startsWith("//")) { - return callbackUrl; - } - return "/"; - }, [callbackUrl]); + const safeCallbackUrl = useMemo(() => normalizeCallbackUrl(callbackUrl), [callbackUrl]); const errorMessage = useMemo(() => { if (!error) { @@ -90,7 +84,7 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable )} {context === "login" ? <> - Don't have an account? Sign up + Don't have an account? Sign up : <> - Already have an account? Sign in + Already have an account? Sign in }

diff --git a/packages/web/src/app/login/components/magicLinkForm.tsx b/packages/web/src/app/login/components/magicLinkForm.tsx index d731436a2..83cc455ae 100644 --- a/packages/web/src/app/login/components/magicLinkForm.tsx +++ b/packages/web/src/app/login/components/magicLinkForm.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useRouter } from "next/navigation"; +import { normalizeCallbackUrl } from "@/lib/authRedirect"; const magicLinkSchema = z.object({ email: z.string().email(), @@ -25,6 +26,7 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => { const captureEvent = useCaptureEvent(); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); + const safeCallbackUrl = normalizeCallbackUrl(callbackUrl); const magicLinkForm = useForm>({ resolver: zodResolver(magicLinkSchema), @@ -37,11 +39,11 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => { setIsLoading(true); captureEvent("wa_login_with_magic_link", {}); - signIn("nodemailer", { email: values.email, redirect: false, redirectTo: callbackUrl ?? "/" }) + signIn("nodemailer", { email: values.email, redirect: false, redirectTo: safeCallbackUrl }) .then(() => { setIsLoading(false); - router.push("/login/verify?email=" + encodeURIComponent(values.email)); + router.push(`/login/verify?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`); }) .catch((error) => { console.error("Error signing in", error); @@ -82,4 +84,4 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => { ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index e84b31754..1ce898806 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -6,6 +6,7 @@ import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { __unsafePrisma } from "@/prisma"; import { env } from "@sourcebot/shared"; import { isAnonymousAccessEnabled } from "@/lib/entitlements"; +import { normalizeCallbackUrl } from "@/lib/authRedirect"; interface LoginProps { searchParams: Promise<{ @@ -16,9 +17,10 @@ interface LoginProps { export default async function Login(props: LoginProps) { const searchParams = await props.searchParams; + const callbackUrl = normalizeCallbackUrl(searchParams.callbackUrl); const session = await auth(); if (session) { - return redirect("/"); + return redirect(callbackUrl); } const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); @@ -32,7 +34,7 @@ export default async function Login(props: LoginProps) {
; +} + +export default async function VerifyPage({ searchParams }: VerifyPageProps) { + const { callbackUrl: rawCallbackUrl } = await searchParams; + const callbackUrl = normalizeCallbackUrl(rawCallbackUrl); const session = await auth(); if (session) { - return redirect("/"); + return redirect(callbackUrl); } return ; diff --git a/packages/web/src/app/login/verify/verifyForm.tsx b/packages/web/src/app/login/verify/verifyForm.tsx index 5f945a9c7..f86e4b168 100644 --- a/packages/web/src/app/login/verify/verifyForm.tsx +++ b/packages/web/src/app/login/verify/verifyForm.tsx @@ -14,12 +14,14 @@ import useCaptureEvent from "@/hooks/useCaptureEvent" import { Footer } from "@/app/components/footer" import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" import { Redirect } from "@/app/components/redirect" +import { normalizeCallbackUrl } from "@/lib/authRedirect" function VerifyPageContent() { const [value, setValue] = useState("") const [isVerifying, setIsVerifying] = useState(false) const searchParams = useSearchParams() const email = searchParams.get("email") + const callbackUrl = normalizeCallbackUrl(searchParams.get("callbackUrl")) const captureEvent = useCaptureEvent(); const handleSubmit = useCallback((code: string) => { @@ -31,11 +33,12 @@ function VerifyPageContent() { const url = new URL("/api/auth/callback/nodemailer", window.location.origin) url.searchParams.set("token", code) url.searchParams.set("email", email) + url.searchParams.set("callbackUrl", callbackUrl) // Use a full-page navigation (not router.push) so the auth callback's // session cookie + 302 redirect are applied by the browser, and the // one-time token isn't consumed twice by a client-side RSC navigation. window.location.href = url.toString() - }, [email, isVerifying]) + }, [callbackUrl, email, isVerifying]) // Auto-submit once the full 6-digit code is entered. Pass the new value // directly rather than reading `value`, which hasn't been committed yet. diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx index b5fe96d4c..311a95ae1 100644 --- a/packages/web/src/app/signup/page.tsx +++ b/packages/web/src/app/signup/page.tsx @@ -6,6 +6,7 @@ import { createLogger, env } from "@sourcebot/shared"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { __unsafePrisma } from "@/prisma"; import { isAnonymousAccessEnabled } from "@/lib/entitlements"; +import { normalizeCallbackUrl } from "@/lib/authRedirect"; const logger = createLogger('signup-page'); @@ -18,10 +19,11 @@ interface LoginProps { export default async function Signup(props: LoginProps) { const searchParams = await props.searchParams; + const callbackUrl = normalizeCallbackUrl(searchParams.callbackUrl); const session = await auth(); if (session) { logger.info("Session found in signup page, redirecting to home"); - return redirect("/"); + return redirect(callbackUrl); } const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); @@ -35,7 +37,7 @@ export default async function Signup(props: LoginProps) {
{ const pathname = usePathname(); + const searchParams = useSearchParams(); + const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`); const [isDismissed, setIsDismissed] = useState(true); // Start as true to avoid flash const [hasDisplayedEventFired, setHasDisplayedEventFired] = useState(false); @@ -76,7 +79,7 @@ export const SignInPromptBanner = ({ asChild onClick={handleSignInClick} > - + Sign in diff --git a/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx index 4896d535b..1ba0eee37 100644 --- a/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx @@ -22,7 +22,8 @@ import { isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon, CableIcon, Loader2Icon, PlusCircleIcon, PlusIcon, RefreshCwIcon, SettingsIcon, SparklesIcon } from "lucide-react"; import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { createLoginUrl } from "@/lib/authRedirect"; import { useEffect, useRef, useState } from "react"; import { useSlate } from "slate-react"; import { Editor } from "slate"; @@ -118,9 +119,10 @@ export const ConnectorsMenu = ({ const queryClient = useQueryClient(); const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); const { toast } = useToast(); const isOwner = useRole() === OrgRole.OWNER; - const loginHref = `/login?callbackUrl=${encodeURIComponent(pathname)}`; + const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`); const { data: servers = [], error, isError, isLoading, refetch } = useQuery({ queryKey: mcpQueryKeys.serversWithStatus, diff --git a/packages/web/src/features/chat/components/chatBox/loginDialog.tsx b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx index 9fc83d257..d1c2a629e 100644 --- a/packages/web/src/features/chat/components/chatBox/loginDialog.tsx +++ b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx @@ -8,7 +8,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; interface LoginDialogProps { isOpen: boolean; @@ -20,6 +20,8 @@ export const LoginDialog = ({ onOpenChange, }: LoginDialogProps) => { const pathname = usePathname(); + const searchParams = useSearchParams(); + const callbackUrl = `${pathname}?${searchParams.toString()}`; return ( @@ -33,7 +35,7 @@ export const LoginDialog = ({
diff --git a/packages/web/src/lib/authRedirect.test.ts b/packages/web/src/lib/authRedirect.test.ts new file mode 100644 index 000000000..d09357b5a --- /dev/null +++ b/packages/web/src/lib/authRedirect.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { createLoginUrl, normalizeCallbackUrl } from './authRedirect'; + +describe('normalizeCallbackUrl', () => { + it('preserves a relative path and query string', () => { + expect(normalizeCallbackUrl('/search?query=auth%20flow&isRegexEnabled=true')) + .toBe('/search?query=auth%20flow&isRegexEnabled=true'); + }); + + it('rejects external and protocol-relative URLs', () => { + expect(normalizeCallbackUrl('https://evil.example.com/login')).toBe('/'); + expect(normalizeCallbackUrl('//evil.example.com/login')).toBe('/'); + expect(normalizeCallbackUrl('search?query=auth')).toBe('/'); + }); + + it('rejects malformed callback values', () => { + expect(normalizeCallbackUrl('\\\\evil.example.com')).toBe('/'); + expect(normalizeCallbackUrl(undefined)).toBe('/'); + }); +}); + +describe('createLoginUrl', () => { + it('encodes the complete callback URL', () => { + expect(createLoginUrl('/chat/thread-1?status=error&message=try%20again')) + .toBe('/login?callbackUrl=%2Fchat%2Fthread-1%3Fstatus%3Derror%26message%3Dtry%2520again'); + }); +}); diff --git a/packages/web/src/lib/authRedirect.ts b/packages/web/src/lib/authRedirect.ts new file mode 100644 index 000000000..43b9b2a6a --- /dev/null +++ b/packages/web/src/lib/authRedirect.ts @@ -0,0 +1,37 @@ +export const REQUEST_PATH_HEADER = 'x-sourcebot-request-path'; + +const CALLBACK_URL_ORIGIN = 'https://sourcebot.invalid'; + +/** + * Return a same-origin relative URL suitable for use as an auth callback. + * + * Callback URLs can come from query parameters, so they must be validated at + * every boundary where they are consumed. Fragments are intentionally kept + * out of server-generated callback URLs because browsers do not send them to + * the server. + */ +export function normalizeCallbackUrl(callbackUrl: unknown): string { + if ( + typeof callbackUrl !== 'string' || + callbackUrl.length === 0 || + !callbackUrl.startsWith('/') || + callbackUrl.startsWith('//') + ) { + return '/'; + } + + try { + const url = new URL(callbackUrl, CALLBACK_URL_ORIGIN); + if (url.origin !== CALLBACK_URL_ORIGIN) { + return '/'; + } + + return `${url.pathname}${url.search}`; + } catch { + return '/'; + } +} + +export function createLoginUrl(callbackUrl: unknown): string { + return `/login?callbackUrl=${encodeURIComponent(normalizeCallbackUrl(callbackUrl))}`; +} diff --git a/packages/web/src/middleware/authenticatedPage.tsx b/packages/web/src/middleware/authenticatedPage.tsx index 515f79523..ed78ce2f3 100644 --- a/packages/web/src/middleware/authenticatedPage.tsx +++ b/packages/web/src/middleware/authenticatedPage.tsx @@ -2,6 +2,8 @@ import { withAuth, withOptionalAuth } from "./withAuth"; import { isServiceError } from "@/lib/utils"; import { Org, OrgRole, PrismaClient, UserWithAccounts } from "@sourcebot/db"; import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { createLoginUrl, REQUEST_PATH_HEADER } from "@/lib/authRedirect"; type RequiredPageAuthContext = { user: UserWithAccounts; @@ -77,7 +79,8 @@ export function authenticatedPage< const result = await withOptionalAuth(async (ctx) => ctx); if (isServiceError(result)) { - redirect('/login'); + const requestHeaders = await headers(); + redirect(createLoginUrl(requestHeaders.get(REQUEST_PATH_HEADER))); } return fn(result as AuthContextFor, props); @@ -85,7 +88,8 @@ export function authenticatedPage< const result = await withAuth(async (ctx) => ctx); if (isServiceError(result)) { - redirect('/login'); + const requestHeaders = await headers(); + redirect(createLoginUrl(requestHeaders.get(REQUEST_PATH_HEADER))); } const requiredOpts = opts as RequiredAuthOptions | undefined; diff --git a/packages/web/src/proxy.ts b/packages/web/src/proxy.ts index 0b07a5198..5dd4247bc 100644 --- a/packages/web/src/proxy.ts +++ b/packages/web/src/proxy.ts @@ -1,6 +1,7 @@ import { StatusCodes } from 'http-status-codes'; import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +import { REQUEST_PATH_HEADER } from './lib/authRedirect'; /** * As part of our original SaaS effort in April 2025, we introduced @@ -36,5 +37,12 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(url, StatusCodes.MOVED_PERMANENTLY); } - return NextResponse.next(); + const requestHeaders = new Headers(request.headers); + requestHeaders.set(REQUEST_PATH_HEADER, `${request.nextUrl.pathname}${request.nextUrl.search}`); + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } From b6b0b934a2c80fa44eadd371d5dd6841379af900 Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:44:42 -0500 Subject: [PATCH 2/2] docs(changelog): add PR link for login redirect fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51798fe53..3df104d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Preserved search and Ask URLs through login redirects. [#1650](https://github.com/sourcebot-dev/sourcebot/pull/1650) + ## [5.1.12] - 2026-09-10 ### Changed