|
2 | 2 | /* SPDX-License-Identifier: MIT */ |
3 | 3 |
|
4 | 4 | import { LoginForm } from "@/components/auth/login-form"; |
5 | | -import { authConfig, getSafeRedirectUrl } from "@/lib/auth-config"; |
| 5 | +import { getSafeRedirectUrl } from "@/lib/auth-config"; |
6 | 6 | import { invalidateSession, sessionQueryOptions } from "@/lib/queries/session"; |
7 | 7 | import { useQueryClient } from "@tanstack/react-query"; |
8 | | -import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; |
9 | | -import { useEffect, useRef, useState } from "react"; |
| 8 | +import { |
| 9 | + createFileRoute, |
| 10 | + isRedirect, |
| 11 | + redirect, |
| 12 | + useRouter, |
| 13 | +} from "@tanstack/react-router"; |
| 14 | +import { z } from "zod"; |
| 15 | + |
| 16 | +// Sanitize returnTo at parse time - consumers get a safe value or undefined |
| 17 | +const searchSchema = z.object({ |
| 18 | + returnTo: z |
| 19 | + .string() |
| 20 | + .optional() |
| 21 | + .transform((val) => { |
| 22 | + const safe = getSafeRedirectUrl(val); |
| 23 | + return safe === "/" ? undefined : safe; |
| 24 | + }) |
| 25 | + .catch(undefined), |
| 26 | +}); |
10 | 27 |
|
11 | 28 | export const Route = createFileRoute("/(auth)/login")({ |
12 | | - // [AUTH GUARD] Redirect authenticated users away from login page |
13 | | - // WARNING: Both user AND session must exist - partial data indicates corrupted session |
14 | | - beforeLoad: async ({ context }) => { |
15 | | - // Fetch fresh session data to ensure we have the latest auth state |
16 | | - const session = await context.queryClient.fetchQuery(sessionQueryOptions()); |
17 | | - |
18 | | - if (session?.user && session?.session) { |
19 | | - throw redirect({ to: "/" }); |
| 29 | + validateSearch: searchSchema, |
| 30 | + beforeLoad: async ({ context, search }) => { |
| 31 | + try { |
| 32 | + const session = await context.queryClient.fetchQuery( |
| 33 | + sessionQueryOptions(), |
| 34 | + ); |
| 35 | + |
| 36 | + // Redirect authenticated users to their destination |
| 37 | + if (session?.user && session?.session) { |
| 38 | + throw redirect({ to: search.returnTo ?? "/" }); |
| 39 | + } |
| 40 | + } catch (error) { |
| 41 | + // Re-throw redirects, show login form for fetch errors |
| 42 | + if (isRedirect(error)) throw error; |
20 | 43 | } |
21 | 44 | }, |
22 | 45 | component: LoginPage, |
23 | | - // [SECURITY] Prevent open redirect attacks by sanitizing all redirect URLs |
24 | | - // Returns "/" for non-relative or suspicious URLs |
25 | | - // |
26 | | - // [OAUTH FLOW] returnUrl signals post-OAuth callback state: |
27 | | - // 1. User initiates social login → redirects to provider |
28 | | - // 2. Provider authenticates → returns to /api/auth/callback/<provider> |
29 | | - // 3. API validates OAuth → redirects here with returnUrl=<destination> |
30 | | - // 4. Component verifies session → auto-navigates to final destination |
31 | | - validateSearch: ( |
32 | | - search: Record<string, unknown>, |
33 | | - ): { redirect: string; returnUrl?: string } => { |
34 | | - return { |
35 | | - redirect: getSafeRedirectUrl(search.redirect), |
36 | | - returnUrl: |
37 | | - typeof search.returnUrl === "string" |
38 | | - ? getSafeRedirectUrl(search.returnUrl) |
39 | | - : undefined, |
40 | | - }; |
41 | | - }, |
42 | 46 | }); |
43 | 47 |
|
44 | 48 | function LoginPage() { |
45 | | - const navigate = useNavigate(); |
| 49 | + const router = useRouter(); |
46 | 50 | const queryClient = useQueryClient(); |
47 | | - const { redirect, returnUrl } = Route.useSearch(); |
48 | | - const [isPostOAuth, setIsPostOAuth] = useState(false); |
49 | | - const [isCheckingAuth, setIsCheckingAuth] = useState(false); |
50 | | - const isMounted = useRef(true); |
51 | | - |
52 | | - // [POST-OAUTH] Auto-redirect authenticated users after OAuth callback |
53 | | - // Triggered when returnUrl present (indicates return from provider) |
54 | | - useEffect(() => { |
55 | | - // Prevent React state updates on unmounted component (memory leak protection) |
56 | | - isMounted.current = true; |
57 | | - |
58 | | - const checkPostOAuthAuth = async () => { |
59 | | - // returnUrl presence confirms OAuth callback - skip check otherwise |
60 | | - if (!returnUrl) return; |
61 | | - |
62 | | - try { |
63 | | - setIsCheckingAuth(true); |
64 | | - |
65 | | - // Fetch fresh session to verify OAuth success |
66 | | - const session = await queryClient.fetchQuery(sessionQueryOptions()); |
67 | | - |
68 | | - // Guard against unmounted component updates |
69 | | - if (!isMounted.current) return; |
70 | | - |
71 | | - if (session?.user && session?.session) { |
72 | | - setIsPostOAuth(true); |
73 | | - |
74 | | - // [CACHE SYNC] Invalidate stale session before navigation |
75 | | - await invalidateSession(queryClient); |
76 | | - |
77 | | - // [AUTO REDIRECT] Route to original destination or default |
78 | | - const finalDestination = |
79 | | - returnUrl || authConfig.oauth.postLoginRedirect; |
80 | | - navigate({ to: finalDestination }).catch(() => { |
81 | | - // Hard redirect fallback ensures navigation on router failure |
82 | | - window.location.href = finalDestination; |
83 | | - }); |
84 | | - } else { |
85 | | - // OAuth incomplete - display login form for retry |
86 | | - setIsCheckingAuth(false); |
87 | | - } |
88 | | - } catch (error) { |
89 | | - console.error("Post-OAuth auth check failed:", error); |
90 | | - if (isMounted.current) { |
91 | | - setIsCheckingAuth(false); |
92 | | - } |
93 | | - } |
94 | | - }; |
95 | | - |
96 | | - checkPostOAuthAuth(); |
97 | | - |
98 | | - // Cleanup: mark component unmounted |
99 | | - return () => { |
100 | | - isMounted.current = false; |
101 | | - }; |
102 | | - }, [returnUrl, queryClient, navigate]); |
| 51 | + const search = Route.useSearch(); |
103 | 52 |
|
104 | 53 | async function handleSuccess() { |
105 | | - // [CACHE SYNC] Invalidate session cache after successful login |
106 | | - // WARNING: Must complete before navigation to prevent stale UI state |
107 | 54 | await invalidateSession(queryClient); |
108 | | - |
109 | | - // Priority: returnUrl (OAuth flow) > redirect (standard flow) |
110 | | - const destination = returnUrl || redirect; |
111 | | - |
112 | | - // Try router navigation first, hard redirect on failure |
113 | | - // NOTE: Hard redirect guarantees navigation despite router state issues |
114 | | - navigate({ to: destination }).catch(() => { |
115 | | - window.location.href = destination; |
116 | | - }); |
117 | | - } |
118 | | - |
119 | | - // [UI STATE] Post-OAuth loading indicator during session verification |
120 | | - if (isPostOAuth) { |
121 | | - return ( |
122 | | - <div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10"> |
123 | | - <div className="w-full max-w-sm text-center"> |
124 | | - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> |
125 | | - <h2 className="text-lg font-semibold mb-2">Completing sign in...</h2> |
126 | | - <p className="text-muted-foreground text-sm"> |
127 | | - You'll be redirected to your destination shortly. |
128 | | - </p> |
129 | | - </div> |
130 | | - </div> |
131 | | - ); |
| 55 | + await router.invalidate(); |
| 56 | + await router.navigate({ to: search.returnTo ?? "/" }); |
132 | 57 | } |
133 | 58 |
|
134 | 59 | return ( |
135 | 60 | <div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10"> |
136 | 61 | <div className="w-full max-w-sm md:max-w-3xl"> |
137 | | - <LoginForm onSuccess={handleSuccess} isLoading={isCheckingAuth} /> |
| 62 | + <LoginForm onSuccess={handleSuccess} /> |
138 | 63 | </div> |
139 | 64 | </div> |
140 | 65 | ); |
|
0 commit comments