From 09979f3bb53bcad0f41afe14346efebea40dc9a3 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 5 Jun 2026 16:50:52 +0800 Subject: [PATCH] fix: handle auth callback failures --- frontend/app/api/auth/me/route.ts | 1 + frontend/app/auth/callback/route.ts | 50 +++++++++++++++++++ frontend/app/auth/login/page.tsx | 17 ++++++- frontend/components/auth/LoginClient.tsx | 9 +++- .../auth/__tests__/passwordReset.test.ts | 26 ++++++++++ .../authTimingInstrumentation.test.ts | 6 +++ 6 files changed, 105 insertions(+), 4 deletions(-) diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 1b493393..bfe5da1d 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -109,6 +109,7 @@ function finishAuthMeResponse( const total = timer.totalMs(); const ownServerTiming = formatServerTiming(timer.stages, total); const backendServerTiming = String(extra?.backendServerTiming || "").trim(); + response.headers.set("Cache-Control", "no-store"); response.headers.set( "Server-Timing", backendServerTiming diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts index 7dd471e9..fb8cab07 100644 --- a/frontend/app/auth/callback/route.ts +++ b/frontend/app/auth/callback/route.ts @@ -14,6 +14,31 @@ function normalizeNextPath(input: string | null) { return raw; } +function normalizeAuthError(input: string | null) { + const fallback = "Authentication failed. Please sign in again."; + const raw = String(input || "") + .replace(/[\r\n]+/g, " ") + .trim(); + if (!raw) return fallback; + return raw.slice(0, 240); +} + +function redirectToLoginWithError({ + baseUrl, + error, + nextPath, +}: { + baseUrl: string; + error: string | null; + nextPath: string; +}) { + const loginUrl = new URL("/auth/login", baseUrl); + loginUrl.searchParams.set("next", nextPath); + loginUrl.searchParams.set("auth_error", "1"); + loginUrl.searchParams.set("error", normalizeAuthError(error)); + return NextResponse.redirect(loginUrl); +} + async function warmSignupTrial(accessToken: string) { const token = String(accessToken || "").trim(); if (!API_BASE || !token) return; @@ -60,6 +85,16 @@ export async function GET(request: NextRequest) { const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next")); const baseUrl = siteUrl || request.nextUrl.origin; const redirectUrl = new URL(nextPath, baseUrl); + const callbackError = + request.nextUrl.searchParams.get("error_description") || + request.nextUrl.searchParams.get("error"); + if (callbackError) { + return redirectToLoginWithError({ + baseUrl, + error: callbackError, + nextPath, + }); + } if (!hasSupabaseServerEnv()) { return NextResponse.redirect(redirectUrl); @@ -71,7 +106,22 @@ export async function GET(request: NextRequest) { if (code) { const { data: { session }, + error: exchangeError, } = await supabase.auth.exchangeCodeForSession(code); + if (exchangeError) { + return redirectToLoginWithError({ + baseUrl, + error: exchangeError.message, + nextPath, + }); + } + if (!session?.access_token) { + return redirectToLoginWithError({ + baseUrl, + error: "Authentication session was not created. Please sign in again.", + nextPath, + }); + } await warmSignupTrial(session?.access_token || ""); } diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx index 71d71141..8dc507f3 100644 --- a/frontend/app/auth/login/page.tsx +++ b/frontend/app/auth/login/page.tsx @@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient"; import { I18nProvider } from "@/hooks/useI18n"; type PageProps = { - searchParams?: Promise<{ next?: string; mode?: string }>; + searchParams?: Promise<{ error?: string; mode?: string; next?: string }>; }; function normalizeNextPath(input: string | undefined) { @@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" { return "login"; } +function normalizeAuthError(input: string | undefined) { + const raw = String(input || "") + .replace(/[\r\n]+/g, " ") + .trim(); + if (!raw) return ""; + return raw.slice(0, 240); +} + export default async function LoginPage({ searchParams }: PageProps) { const params = (await searchParams) || {}; const nextPath = normalizeNextPath(params.next); const initialMode = normalizeMode(params.mode); + const initialError = normalizeAuthError(params.error); return ( - + ); } diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index f27bde30..d4a1fcd8 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -26,17 +26,18 @@ type Mode = "login" | "signup"; type LoginClientProps = { nextPath: string; + initialError?: string; initialMode?: Mode; }; -export function LoginClient({ nextPath, initialMode }: LoginClientProps) { +export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) { const router = useRouter(); const { locale } = useI18n(); const [mode, setMode] = useState(initialMode ?? "login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - const [errorText, setErrorText] = useState(""); + const [errorText, setErrorText] = useState(initialError || ""); const [infoText, setInfoText] = useState(""); const [resetSent, setResetSent] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -121,6 +122,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { /> ); + useEffect(() => { + setErrorText(initialError || ""); + }, [initialError]); + const onResetPassword = async () => { setErrorText(""); setInfoText(""); diff --git a/frontend/components/auth/__tests__/passwordReset.test.ts b/frontend/components/auth/__tests__/passwordReset.test.ts index 13c32f1b..b68b3f64 100644 --- a/frontend/components/auth/__tests__/passwordReset.test.ts +++ b/frontend/components/auth/__tests__/passwordReset.test.ts @@ -33,8 +33,16 @@ export function runTests() { "callback", "route.ts", ); + const loginPagePath = path.join( + projectRoot, + "app", + "auth", + "login", + "page.tsx", + ); const loginClientSource = fs.readFileSync(loginClientPath, "utf8"); + const loginPageSource = fs.readFileSync(loginPagePath, "utf8"); assert( loginClientSource.includes("/auth/reset-password") && @@ -80,4 +88,22 @@ export function runTests() { authCallbackSource.includes("Bearer"), "auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately", ); + assert( + loginPageSource.includes("error?: string") && + loginPageSource.includes("normalizeAuthError") && + loginPageSource.includes("params.error") && + loginPageSource.includes("initialError={initialError}") && + loginClientSource.includes("initialError?: string") && + loginClientSource.includes("useState(initialError || \"\")"), + "login page must surface auth callback errors in the login form instead of silently returning users to the terminal", + ); + assert( + authCallbackSource.includes("redirectToLoginWithError") && + authCallbackSource.includes('request.nextUrl.searchParams.get("error_description")') && + authCallbackSource.includes('request.nextUrl.searchParams.get("error")') && + authCallbackSource.includes("exchangeError") && + authCallbackSource.includes("exchangeCodeForSession(code)") && + authCallbackSource.includes("auth_error"), + "auth callback must redirect failed OAuth/session exchanges back to login with an error message", + ); } diff --git a/frontend/components/ops/__tests__/authTimingInstrumentation.test.ts b/frontend/components/ops/__tests__/authTimingInstrumentation.test.ts index 056c03ea..c5d7ba1b 100644 --- a/frontend/components/ops/__tests__/authTimingInstrumentation.test.ts +++ b/frontend/components/ops/__tests__/authTimingInstrumentation.test.ts @@ -24,6 +24,12 @@ export function runTests() { authMeRouteSource.includes("total"), "/api/auth/me proxy must expose stage durations through Server-Timing for HAR inspection", ); + assert( + authMeRouteSource.includes('response.headers.set("Cache-Control", "no-store")') && + authMeRouteSource.indexOf('response.headers.set("Cache-Control", "no-store")') > + authMeRouteSource.indexOf("function finishAuthMeResponse"), + "/api/auth/me proxy must mark every auth profile response no-store so anonymous state cannot be reused after login", + ); const finishStart = authMeRouteSource.indexOf("function finishAuthMeResponse"); const finishEnd = authMeRouteSource.indexOf("async function trackAuthDiagnosticEvent"); const finishSource =