Skip to content

feat(cli): TanStack Router beforeLoad auth checks #439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/salty-windows-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"create-better-t-stack": patch
---

TanStack Router checks for auth in the beforeLoad function of a Route
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
Expand All @@ -13,6 +13,7 @@ export default function SignInForm({
}: {
onSwitchToSignUp: () => void;
}) {
const search = useSearch({ from: "/login" });
const navigate = useNavigate({
from: "/",
});
Expand All @@ -32,7 +33,7 @@ export default function SignInForm({
{
onSuccess: () => {
navigate({
to: "/dashboard",
to: search.redirect,
});
Comment on lines 35 to 37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate redirect URL to prevent open redirects

Using search.redirect directly for navigation creates a security vulnerability. Malicious actors could craft URLs with arbitrary redirect destinations, leading to open redirect attacks.

Add validation to ensure the redirect URL is safe:

  onSuccess: () => {
+   const safeRedirect = search.redirect?.startsWith('/') ? search.redirect : '/dashboard';
    navigate({
-     to: search.redirect,
+     to: safeRedirect,
    });
    toast.success("Sign in successful");
  },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
navigate({
to: "/dashboard",
to: search.redirect,
});
onSuccess: () => {
const safeRedirect = search.redirect?.startsWith('/') ? search.redirect : '/dashboard';
navigate({
to: safeRedirect,
});
toast.success("Sign in successful");
},
🤖 Prompt for AI Agents
In
apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-in-form.tsx
around lines 35 to 37, the code uses search.redirect directly for navigation,
which can cause open redirect vulnerabilities. To fix this, add validation to
check that the redirect URL is safe and allowed before calling navigate.
Implement a whitelist or ensure the redirect URL is relative and does not lead
to external sites, and only navigate if the URL passes this validation.

toast.success("Sign in successful");
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
Expand All @@ -13,6 +13,7 @@ export default function SignUpForm({
}: {
onSwitchToSignIn: () => void;
}) {
const search = useSearch({ from: "/login" });
const navigate = useNavigate({
from: "/",
});
Expand All @@ -34,7 +35,7 @@ export default function SignUpForm({
{
onSuccess: () => {
navigate({
to: "/dashboard",
to: search.redirect,
});
toast.success("Sign up successful");
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";

export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ context }) => {
if (!context.user) {
throw redirect({
to: "/login",
search: {
redirect: `${location.pathname}${location.search}${location.hash}`,
},
});
}
},
component: () => <Outlet />,
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,22 @@ import { trpc } from "@/utils/trpc";
import { useQuery } from "@tanstack/react-query";
{{/if}}
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";

export const Route = createFileRoute("/dashboard")({
export const Route = createFileRoute("/_auth/dashboard")({
component: RouteComponent,
});

function RouteComponent() {
const { data: session, isPending } = authClient.useSession();

const navigate = Route.useNavigate();

{{#if (eq api "orpc")}}

const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}

const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}

useEffect(() => {
if (!session && !isPending) {
navigate({
to: "/login",
});
}
}, [session, isPending]);

if (isPending) {
return <div>Loading...</div>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";

const fallbackRedirect = "/dashboard";

export const Route = createFileRoute("/login")({
validateSearch: z.object({
redirect: z.string().default(fallbackRedirect),
}),
beforeLoad: async ({ context, search }) => {
if (context.user) {
throw redirect({ to: search.redirect });
}
},
component: RouteComponent,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const router = createRouter({
defaultPreload: "intent",
defaultPendingComponent: () => <Loader />,
{{#if (eq api "orpc")}}
context: { orpc, queryClient },
context: { orpc, queryClient{{#if auth}}, user: null{{/if}} },
Wrap: function WrapComponent({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
Expand All @@ -30,7 +30,7 @@ const router = createRouter({
);
},
{{else if (eq api "trpc")}}
context: { trpc, queryClient },
context: { trpc, queryClient{{#if auth}}, user: null{{/if}} },
Wrap: function WrapComponent({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
Expand All @@ -44,7 +44,9 @@ const router = createRouter({
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
},
{{else}}
context: {},
context: {
{{#if auth}}user: null{{/if}}
},
{{/if}}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import type { trpc } from "@/utils/trpc";
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
{{/if}}
{{#if auth}}
import { authClient } from "@/lib/auth-client";
import type { User } from "better-auth";
{{/if}}
import {
HeadContent,
Outlet,
Expand All @@ -30,17 +34,38 @@ import "../index.css";
export interface RouterAppContext {
orpc: typeof orpc;
queryClient: QueryClient;
{{#if auth}}
user: User | null;
{{/if}}
}
{{else if (eq api "trpc")}}
export interface RouterAppContext {
trpc: typeof trpc;
queryClient: QueryClient;
{{#if auth}}
user: User | null;
{{/if}}
}
{{else}}
export interface RouterAppContext {}
export interface RouterAppContext {
{{#if auth}}
user: User | null;
{{/if}}
}
{{/if}}

export const Route = createRootRouteWithContext<RouterAppContext>()({
{{#if auth}}
beforeLoad: async () => {
try {
const session = await authClient.getSession();
return { user: session.data?.user ?? null };
} catch (error) {
console.error('Failed to fetch user session:', error);
return { user: null };
}
},
{{/if}}
component: RootComponent,
head: () => ({
meta: [
Expand All @@ -65,8 +90,8 @@ function RootComponent() {
const isFetching = useRouterState({
select: (s) => s.isLoading,
});

{{#if (eq api "orpc")}}

const [client] = useState<RouterClient<typeof appRouter>>(() => createORPCClient(link));
const [orpcUtils] = useState(() => createTanstackQueryUtils(client));
{{/if}}
Expand Down