fix(auth): recover from setup status timeouts (#4371)

* fix(auth): recover from setup status timeouts

* test(auth): cover setup status recovery flows

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
urzeye 2026-07-26 10:05:05 +08:00 committed by GitHub
parent 183280ebfc
commit f881996e1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 394 additions and 25 deletions

View File

@ -54,6 +54,12 @@ jobs:
env:
SKIP_ENV_VALIDATION: '1'
- name: Run auth recovery E2E tests
working-directory: frontend
run: pnpm exec playwright test -c playwright.auth.config.ts
env:
SKIP_ENV_VALIDATION: '1'
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}

View File

@ -0,0 +1,49 @@
import { defineConfig, devices } from "@playwright/test";
const frontendPort = process.env.E2E_AUTH_FRONTEND_PORT ?? "3001";
const frontendURL =
process.env.PLAYWRIGHT_AUTH_BASE_URL ?? `http://localhost:${frontendPort}`;
const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1";
/**
* Auth-enabled E2E coverage for the login and setup pages. The default config
* runs with auth disabled so workspace tests can skip a real session; that
* would SSR-redirect these pages before page.route() can exercise recovery.
*/
export default defineConfig({
testDir: "./tests/e2e-auth",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
timeout: 30_000,
use: {
baseURL: frontendURL,
locale: "en-US",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: skipWebServer
? undefined
: {
command: `pnpm build && pnpm exec next start -p ${frontendPort}`,
url: frontendURL,
reuseExistingServer: !process.env.CI,
timeout: 240_000,
env: {
SKIP_ENV_VALIDATION: "1",
DEER_FLOW_AUTH_DISABLED: "0",
DEER_FLOW_INTERNAL_GATEWAY_BASE_URL: "http://127.0.0.1:65535",
DEER_FLOW_TRUSTED_ORIGINS: frontendURL,
},
},
});

View File

@ -72,7 +72,10 @@ export default function LoginPage() {
const [setupStatus, setSetupStatus] = useState<SetupStatusResponse | null>(
null,
);
const [setupStatusChecked, setSetupStatusChecked] = useState(false);
const [setupStatusPhase, setSetupStatusPhase] = useState<
"checking" | "ready" | "unavailable"
>("checking");
const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
@ -93,10 +96,15 @@ export default function LoginPage() {
const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
const regularSignupAllowed = canCreateRegularAccount({
checked: setupStatusChecked,
// A failed probe must not expose registration while the system's setup
// state is unknown. Existing users can still sign in normally.
checked: setupStatusPhase === "ready",
status: setupStatus,
});
const systemNeedsAdminSetup = setupStatus?.needs_setup === true;
const showSetupStatusUnavailable =
setupStatusPhase === "unavailable" ||
(setupStatusAttempt > 0 && setupStatusPhase === "checking");
// Redirect if already authenticated (client-side, post-login)
useEffect(() => {
@ -113,14 +121,17 @@ export default function LoginPage() {
}
}, []);
// Fetch setup state and SSO providers
// Fetch setup state independently so retrying a slow Gateway does not also
// refetch unrelated auth-provider configuration.
useEffect(() => {
let cancelled = false;
setSetupStatusPhase("checking");
void fetchSetupStatus()
.then((data) => {
if (cancelled) return;
setSetupStatus(data);
setSetupStatusPhase("ready");
if (data.needs_setup) {
setIsLogin(true);
}
@ -128,14 +139,20 @@ export default function LoginPage() {
.catch(() => {
if (!cancelled) {
setSetupStatus(null);
}
})
.finally(() => {
if (!cancelled) {
setSetupStatusChecked(true);
setSetupStatusPhase("unavailable");
}
});
return () => {
cancelled = true;
};
}, [setupStatusAttempt]);
// SSO providers are static for the page lifetime and should not be coupled to
// setup-status retries.
useEffect(() => {
let cancelled = false;
void fetch("/api/v1/auth/providers")
.then((r) => r.json())
.then(
@ -234,6 +251,34 @@ export default function LoginPage() {
</p>
</div>
{showSetupStatusUnavailable && (
<div
role="status"
aria-live="polite"
className="border-l-2 border-amber-500 ps-3 text-sm"
>
<p className="font-medium">{t.login.serviceUnavailableTitle}</p>
<p className="text-muted-foreground mt-1">
{t.login.serviceUnavailableDescription}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={setupStatusPhase === "checking"}
onClick={() => {
setSetupStatusPhase("checking");
setSetupStatusAttempt((attempt) => attempt + 1);
}}
>
{setupStatusPhase === "checking"
? t.login.pleaseWait
: t.login.retry}
</Button>
</div>
)}
{systemNeedsAdminSetup && (
<div className="border-l-2 border-blue-500 ps-3 text-sm">
<p className="font-medium">{t.login.adminSetupRequiredTitle}</p>

View File

@ -16,14 +16,17 @@ import {
isSystemAlreadyInitializedError,
} from "@/core/auth/setup";
import { parseAuthError } from "@/core/auth/types";
import { useI18n } from "@/core/i18n/hooks";
type SetupMode = "loading" | "init_admin" | "change_password";
type SetupMode = "loading" | "init_admin" | "change_password" | "unavailable";
export default function SetupPage() {
const router = useRouter();
const { user, isAuthenticated } = useAuth();
const { theme, resolvedTheme } = useTheme();
const { t } = useI18n();
const [mode, setMode] = useState<SetupMode>("loading");
const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// --- Shared state ---
const [email, setEmail] = useState("");
@ -44,7 +47,9 @@ export default function SetupPage() {
if (isAuthenticated && user?.needs_setup) {
setMode("change_password");
} else if (!isAuthenticated) {
// Check if the system has no users yet
// Check if the system has no users yet. A slow Gateway must not leave the
// setup page in an infinite loading state or silently redirect away.
setMode("loading");
void fetchSetupStatus()
.then((data: { needs_setup?: boolean }) => {
if (cancelled) return;
@ -56,7 +61,7 @@ export default function SetupPage() {
}
})
.catch(() => {
if (!cancelled) router.replace("/login");
if (!cancelled) setMode("unavailable");
});
} else {
// Authenticated but needs_setup is false — already set up
@ -66,7 +71,7 @@ export default function SetupPage() {
return () => {
cancelled = true;
};
}, [isAuthenticated, user, router]);
}, [isAuthenticated, user, router, setupStatusAttempt]);
// ── Init-admin handler ─────────────────────────────────────────────
const handleInitAdmin = async (e: React.SubmitEvent) => {
@ -166,6 +171,41 @@ export default function SetupPage() {
);
}
if (mode === "unavailable") {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md space-y-4 text-center">
<div>
<h1 className="text-xl font-semibold">
{t.login.serviceUnavailableTitle}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t.login.serviceUnavailableDescription}
</p>
</div>
<div className="flex justify-center gap-3">
<Button
type="button"
onClick={() => {
setMode("loading");
setSetupStatusAttempt((attempt) => attempt + 1);
}}
>
{t.login.retry}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.replace("/login")}
>
{t.login.signIn}
</Button>
</div>
</div>
</div>
);
}
// ── Admin initialization form ──────────────────────────────────────
if (mode === "init_admin") {
return (

View File

@ -0,0 +1 @@
export const AUTH_REQUEST_TIMEOUT_MS = 5_000;

View File

@ -3,12 +3,11 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
const SSR_AUTH_TIMEOUT_MS = 5_000;
/**
* Fetch the authenticated user from the gateway using the request's cookies.
* Returns a tagged AuthResult callers use exhaustive switch, no try/catch.
@ -43,7 +42,7 @@ export async function getServerSideUser(): Promise<AuthResult> {
const setupController = new AbortController();
const setupTimeout = setTimeout(
() => setupController.abort(),
SSR_AUTH_TIMEOUT_MS,
AUTH_REQUEST_TIMEOUT_MS,
);
try {
const setupRes = await fetch(
@ -68,7 +67,7 @@ export async function getServerSideUser(): Promise<AuthResult> {
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {

View File

@ -1,3 +1,4 @@
import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { parseAuthError } from "./types";
export type SetupStatusResponse = {
@ -16,14 +17,21 @@ export const setupStatusFetchInit = {
} satisfies RequestInit;
export async function fetchSetupStatus(): Promise<SetupStatusResponse> {
const response = await fetch(
"/api/v1/auth/setup-status",
setupStatusFetchInit,
);
if (!response.ok) {
throw new Error(`setup-status failed: ${response.status}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
try {
const response = await fetch("/api/v1/auth/setup-status", {
...setupStatusFetchInit,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`setup-status failed: ${response.status}`);
}
return (await response.json()) as SetupStatusResponse;
} finally {
clearTimeout(timeout);
}
return (await response.json()) as SetupStatusResponse;
}
export function isSystemAlreadyInitializedError(data: unknown): boolean {

View File

@ -1092,6 +1092,10 @@ export const enUS: Translations = {
haveAccountSignIn: "Already have an account? Sign in",
backToHome: "← Back to home",
networkError: "Network error. Please try again.",
serviceUnavailableTitle: "Service temporarily unavailable",
serviceUnavailableDescription:
"The Gateway is taking too long to respond. Check that it is running, then try again.",
retry: "Try again",
authFailed: "Authentication failed.",
errors: {
sso_failed: "SSO login failed. Please try again or use email login.",

View File

@ -862,6 +862,9 @@ export interface Translations {
haveAccountSignIn: string;
backToHome: string;
networkError: string;
serviceUnavailableTitle: string;
serviceUnavailableDescription: string;
retry: string;
authFailed: string;
errors: {
sso_failed: string;

View File

@ -1043,6 +1043,10 @@ export const zhCN: Translations = {
haveAccountSignIn: "已有账号?立即登录",
backToHome: "← 返回首页",
networkError: "网络错误,请重试。",
serviceUnavailableTitle: "服务暂时不可用",
serviceUnavailableDescription:
"网关响应时间过长。请确认服务正在运行,然后重试。",
retry: "重试",
authFailed: "身份验证失败。",
errors: {
sso_failed: "SSO 登录失败,请重试或使用邮箱登录。",

View File

@ -0,0 +1,102 @@
import { expect, test, type Page } from "@playwright/test";
const SETUP_STATUS_URL = "**/api/v1/auth/setup-status";
const SERVICE_UNAVAILABLE_TITLE = "Service temporarily unavailable";
async function mockSetupStatusRecovery(
page: Page,
recoveredStatus: {
needs_setup: boolean;
registration_enabled?: boolean;
},
) {
let requestCount = 0;
await page.route(SETUP_STATUS_URL, (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
requestCount += 1;
if (requestCount === 1) {
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "Gateway unavailable" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(recoveredStatus),
});
});
return () => requestCount;
}
test.describe("auth setup-status recovery", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/auth/providers", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ providers: [] }),
}),
);
});
test("login restores registration after setup-status retry", async ({
page,
}) => {
const getRequestCount = await mockSetupStatusRecovery(page, {
needs_setup: false,
registration_enabled: true,
});
await page.goto("/login");
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
await expect(page.getByRole("button", { name: "Sign In" })).toBeEnabled();
await expect(
page.getByRole("button", { name: /Don't have an account/i }),
).toHaveCount(0);
expect(getRequestCount()).toBe(1);
await page.getByRole("button", { name: "Try again" }).click();
await expect
.poll(getRequestCount, { message: "setup-status should be retried" })
.toBe(2);
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
await expect(
page.getByRole("button", { name: /Don't have an account/i }),
).toBeVisible();
});
test("setup restores the administrator form after setup-status retry", async ({
page,
}) => {
const getRequestCount = await mockSetupStatusRecovery(page, {
needs_setup: true,
});
await page.goto("/setup");
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
expect(getRequestCount()).toBe(1);
await page.getByRole("button", { name: "Try again" }).click();
await expect
.poll(getRequestCount, { message: "setup-status should be retried" })
.toBe(2);
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
await expect(
page.getByRole("button", { name: "Create Admin Account" }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
});
});

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
import { AUTH_REQUEST_TIMEOUT_MS } from "@/core/auth/constants";
import {
canCreateRegularAccount,
fetchSetupStatus,
@ -7,8 +8,32 @@ import {
setupStatusFetchInit,
} from "@/core/auth/setup";
function pendingUntilAborted(signal: AbortSignal): Promise<Response> {
return new Promise((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
reject(
signal.reason instanceof Error
? signal.reason
: new Error("The request was aborted"),
);
},
{ once: true },
);
});
}
function requestSignal(init?: RequestInit): AbortSignal {
if (!init?.signal) {
throw new Error("Expected fetch to receive an AbortSignal");
}
return init.signal;
}
describe("auth setup helpers", () => {
afterEach(() => {
rs.useRealTimers();
rs.unstubAllGlobals();
});
@ -20,7 +45,7 @@ describe("auth setup helpers", () => {
});
test("fetchSetupStatus uses the shared no-store request options", async () => {
const fetchMock = rs.fn(() =>
const fetchMock = rs.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), {
status: 200,
@ -33,7 +58,90 @@ describe("auth setup helpers", () => {
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/auth/setup-status",
setupStatusFetchInit,
expect.objectContaining(setupStatusFetchInit),
);
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
});
test("aborts a setup-status request that remains pending", async () => {
rs.useFakeTimers();
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) =>
pendingUntilAborted(requestSignal(init)),
);
rs.stubGlobal("fetch", fetchMock);
const request = fetchSetupStatus();
const rejection = expect(request).rejects.toMatchObject({
name: "AbortError",
});
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
await rejection;
expect(fetchMock.mock.calls[0]![1]!.signal!.aborted).toBe(true);
});
test("clears the timeout after a successful setup-status response", async () => {
rs.useFakeTimers();
const signals: AbortSignal[] = [];
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
signals.push(requestSignal(init));
return Promise.resolve(
new Response(JSON.stringify({ needs_setup: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
rs.stubGlobal("fetch", fetchMock);
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: false });
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
expect(signals[0]!.aborted).toBe(false);
});
test("a retry uses a fresh signal after the first request times out", async () => {
rs.useFakeTimers();
const signals: AbortSignal[] = [];
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = requestSignal(init);
signals.push(signal);
if (signals.length === 1) {
return pendingUntilAborted(signal);
}
return Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
rs.stubGlobal("fetch", fetchMock);
const firstRequest = fetchSetupStatus();
const firstRejection = expect(firstRequest).rejects.toMatchObject({
name: "AbortError",
});
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
await firstRejection;
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(signals).toHaveLength(2);
expect(signals[1]!).not.toBe(signals[0]!);
expect(signals[0]!.aborted).toBe(true);
expect(signals[1]!.aborted).toBe(false);
});
test("preserves setup-status HTTP errors", async () => {
rs.stubGlobal(
"fetch",
rs.fn(() => Promise.resolve(new Response(null, { status: 503 }))),
);
await expect(fetchSetupStatus()).rejects.toThrow(
"setup-status failed: 503",
);
});