From 004d34cc450e6a4bdd6ce82b86e9e22de8067fe0 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 10 Apr 2026 18:44:47 -0400 Subject: [PATCH] Fix login redirect callback --- apps/dashboard/src/lib/auth-actions.ts | 14 +++++++--- apps/dashboard/src/lib/auth-redirect.test.ts | 25 ++++++++++++++++++ apps/dashboard/src/lib/auth-redirect.ts | 27 ++++++++++++++++++++ apps/dashboard/src/routes/login.tsx | 12 ++++++--- 4 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/src/lib/auth-redirect.test.ts create mode 100644 apps/dashboard/src/lib/auth-redirect.ts diff --git a/apps/dashboard/src/lib/auth-actions.ts b/apps/dashboard/src/lib/auth-actions.ts index 055ad5f..69e1b05 100644 --- a/apps/dashboard/src/lib/auth-actions.ts +++ b/apps/dashboard/src/lib/auth-actions.ts @@ -1,9 +1,15 @@ import { createClientOnlyFn } from "@tanstack/react-start"; +import { normalizeAuthRedirect } from "./auth-redirect"; -export const signInWithGitHub = createClientOnlyFn(async () => { - const { signIn } = await import("./auth.client"); - return signIn.social({ provider: "github" }); -}); +export const signInWithGitHub = createClientOnlyFn( + async ({ redirect }: { redirect?: string } = {}) => { + const { signIn } = await import("./auth.client"); + return signIn.social({ + provider: "github", + callbackURL: normalizeAuthRedirect(redirect), + }); + }, +); export const signOutToLogin = createClientOnlyFn(async () => { const { signOut } = await import("./auth.client"); diff --git a/apps/dashboard/src/lib/auth-redirect.test.ts b/apps/dashboard/src/lib/auth-redirect.test.ts new file mode 100644 index 0000000..7b1a53c --- /dev/null +++ b/apps/dashboard/src/lib/auth-redirect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAuthRedirect } from "./auth-redirect"; + +describe("normalizeAuthRedirect", () => { + it("keeps relative app paths with search and hash", () => { + expect( + normalizeAuthRedirect("/stylessh/quickhub/pull.12?tab=files#comment-1"), + ).toBe("/stylessh/quickhub/pull.12?tab=files#comment-1"); + }); + + it("falls back for missing or non-string values", () => { + expect(normalizeAuthRedirect(undefined)).toBe("/"); + expect(normalizeAuthRedirect(123)).toBe("/"); + }); + + it("rejects absolute and protocol-relative URLs", () => { + expect(normalizeAuthRedirect("https://example.com/pulls")).toBe("/"); + expect(normalizeAuthRedirect("//example.com/pulls")).toBe("/"); + }); + + it("rejects redirects back to login", () => { + expect(normalizeAuthRedirect("/login")).toBe("/"); + expect(normalizeAuthRedirect("/login?redirect=/pulls")).toBe("/"); + }); +}); diff --git a/apps/dashboard/src/lib/auth-redirect.ts b/apps/dashboard/src/lib/auth-redirect.ts new file mode 100644 index 0000000..8a7220e --- /dev/null +++ b/apps/dashboard/src/lib/auth-redirect.ts @@ -0,0 +1,27 @@ +export const DEFAULT_AUTH_REDIRECT = "/"; + +const AUTH_REDIRECT_BASE_URL = "https://diffkit.local"; + +export function normalizeAuthRedirect(value: unknown) { + if (typeof value !== "string") { + return DEFAULT_AUTH_REDIRECT; + } + + const redirect = value.trim(); + if (!redirect.startsWith("/") || redirect.startsWith("//")) { + return DEFAULT_AUTH_REDIRECT; + } + + let url: URL; + try { + url = new URL(redirect, AUTH_REDIRECT_BASE_URL); + } catch { + return DEFAULT_AUTH_REDIRECT; + } + + if (url.origin !== AUTH_REDIRECT_BASE_URL || url.pathname === "/login") { + return DEFAULT_AUTH_REDIRECT; + } + + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index 99a7b2c..bcd2746 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -4,6 +4,7 @@ import { Logo } from "@diffkit/ui/components/logo"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { getSession } from "#/lib/auth.functions"; import { signInWithGitHub } from "#/lib/auth-actions"; +import { normalizeAuthRedirect } from "#/lib/auth-redirect"; import { buildSeo, buildSoftwareApplicationSchema, @@ -12,9 +13,12 @@ import { import { siteConfig } from "#/lib/site-config"; export const Route = createFileRoute("/login")({ - beforeLoad: async () => { + validateSearch: (search) => ({ + redirect: normalizeAuthRedirect(search.redirect), + }), + beforeLoad: async ({ search }) => { const session = await getSession(); - if (session) throw redirect({ to: "/" }); + if (session) throw redirect({ href: search.redirect }); }, head: () => { const seo = buildSeo({ @@ -43,6 +47,8 @@ export const Route = createFileRoute("/login")({ }); function LoginPage() { + const { redirect } = Route.useSearch(); + return (
@@ -79,7 +85,7 @@ function LoginPage() { className="space-y-3" onSubmit={(event) => { event.preventDefault(); - void signInWithGitHub(); + void signInWithGitHub({ redirect }); }} >