fix: handle auth callback failures
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user