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 =