fix: handle auth callback failures

This commit is contained in:
2569718930@qq.com
2026-06-05 16:50:52 +08:00
parent e6a1d14b09
commit 09979f3bb5
6 changed files with 105 additions and 4 deletions
+1
View File
@@ -109,6 +109,7 @@ function finishAuthMeResponse(
const total = timer.totalMs(); const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total); const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim(); const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set( response.headers.set(
"Server-Timing", "Server-Timing",
backendServerTiming backendServerTiming
+50
View File
@@ -14,6 +14,31 @@ function normalizeNextPath(input: string | null) {
return raw; 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) { async function warmSignupTrial(accessToken: string) {
const token = String(accessToken || "").trim(); const token = String(accessToken || "").trim();
if (!API_BASE || !token) return; if (!API_BASE || !token) return;
@@ -60,6 +85,16 @@ export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next")); const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const baseUrl = siteUrl || request.nextUrl.origin; const baseUrl = siteUrl || request.nextUrl.origin;
const redirectUrl = new URL(nextPath, baseUrl); 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()) { if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl); return NextResponse.redirect(redirectUrl);
@@ -71,7 +106,22 @@ export async function GET(request: NextRequest) {
if (code) { if (code) {
const { const {
data: { session }, data: { session },
error: exchangeError,
} = await supabase.auth.exchangeCodeForSession(code); } = 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 || ""); await warmSignupTrial(session?.access_token || "");
} }
+15 -2
View File
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n"; import { I18nProvider } from "@/hooks/useI18n";
type PageProps = { type PageProps = {
searchParams?: Promise<{ next?: string; mode?: string }>; searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
}; };
function normalizeNextPath(input: string | undefined) { function normalizeNextPath(input: string | undefined) {
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
return "login"; 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) { export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {}; const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next); const nextPath = normalizeNextPath(params.next);
const initialMode = normalizeMode(params.mode); const initialMode = normalizeMode(params.mode);
const initialError = normalizeAuthError(params.error);
return ( return (
<I18nProvider> <I18nProvider>
<LoginClient nextPath={nextPath} initialMode={initialMode} /> <LoginClient
nextPath={nextPath}
initialMode={initialMode}
initialError={initialError}
/>
</I18nProvider> </I18nProvider>
); );
} }
+7 -2
View File
@@ -26,17 +26,18 @@ type Mode = "login" | "signup";
type LoginClientProps = { type LoginClientProps = {
nextPath: string; nextPath: string;
initialError?: string;
initialMode?: Mode; initialMode?: Mode;
}; };
export function LoginClient({ nextPath, initialMode }: LoginClientProps) { export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
const router = useRouter(); const router = useRouter();
const { locale } = useI18n(); const { locale } = useI18n();
const [mode, setMode] = useState<Mode>(initialMode ?? "login"); const [mode, setMode] = useState<Mode>(initialMode ?? "login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState(initialError || "");
const [infoText, setInfoText] = useState(""); const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false); const [resetSent, setResetSent] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -121,6 +122,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
/> />
); );
useEffect(() => {
setErrorText(initialError || "");
}, [initialError]);
const onResetPassword = async () => { const onResetPassword = async () => {
setErrorText(""); setErrorText("");
setInfoText(""); setInfoText("");
@@ -33,8 +33,16 @@ export function runTests() {
"callback", "callback",
"route.ts", "route.ts",
); );
const loginPagePath = path.join(
projectRoot,
"app",
"auth",
"login",
"page.tsx",
);
const loginClientSource = fs.readFileSync(loginClientPath, "utf8"); const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
const loginPageSource = fs.readFileSync(loginPagePath, "utf8");
assert( assert(
loginClientSource.includes("/auth/reset-password") && loginClientSource.includes("/auth/reset-password") &&
@@ -80,4 +88,22 @@ export function runTests() {
authCallbackSource.includes("Bearer"), authCallbackSource.includes("Bearer"),
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately", "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",
);
} }
@@ -24,6 +24,12 @@ export function runTests() {
authMeRouteSource.includes("total"), authMeRouteSource.includes("total"),
"/api/auth/me proxy must expose stage durations through Server-Timing for HAR inspection", "/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 finishStart = authMeRouteSource.indexOf("function finishAuthMeResponse");
const finishEnd = authMeRouteSource.indexOf("async function trackAuthDiagnosticEvent"); const finishEnd = authMeRouteSource.indexOf("async function trackAuthDiagnosticEvent");
const finishSource = const finishSource =