fix(auth): recover session checks after network failures (#7529)

* fix(auth): recover session checks after network failures

* fix(auth): retain reconnect wakeups during pending refreshes

* docs(auth): scope recovery guidance within budget

* fix(auth): preserve reconnects until tabs become visible

* test: include auth guidance in approved inventory

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
JasonH 2026-10-26 19:29:33 +08:00 committed by GitHub
parent 9c00fd839a
commit 5dc508b407
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 449 additions and 15 deletions

View File

@ -637,6 +637,13 @@ The bundled Docker, local and Helm nginx configs proxy Live Browser WebSocket up
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
If an authentication refresh fails during a temporary network outage, a visible
tab rechecks the unknown session when connectivity returns or on a later tab
switch. Reconnects received in a background tab are remembered until it becomes
visible, without waiting for the tab-switch throttle. Identity is restored only
after a successful auth response; a confirmed expired session or explicit logout
still requires signing in again.
Password forms accept non-ASCII email addresses and trim surrounding email whitespace.
Sign-in requires at least six password characters; creating an account or choosing
a new password requires eight. These checks run before sending a request.

View File

@ -55,6 +55,7 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/utils/AGENTS.md",
"frontend/src/AGENTS.md",
"frontend/src/components/workspace/browser-view/AGENTS.md",
"frontend/src/core/auth/AGENTS.md",
"frontend/src/core/memory/AGENTS.md",
"frontend/tests/e2e/AGENTS.md",
"scripts/AGENTS.md",

View File

@ -106,14 +106,8 @@ Users create **threads** (conversations), send messages, set thread-scoped `/goa
### Source Layout (`src/`)
`AuthProvider` publishes the query owner (user ID, confirmed logout, or unknown
auth). `QueryClientProvider` owns a mount-local client and remounts its consumers
when that owner changes, clearing the old cache and cancelling old queries.
Confirmed logout unmounts query consumers immediately; public and unknown-auth
routes still render with an independent client. Keep auth outside the query
provider. Superseded auth refreshes cannot restore an earlier owner.
Auth: trim emails; validate raw passwords.
Read [auth ownership and recovery](src/core/auth/AGENTS.md) before changing
`AuthProvider` or `QueryClientProvider`. Auth forms trim emails, not passwords.
See [project structure](README.md#project-structure) for the directory map and
[src/AGENTS.md](src/AGENTS.md) for component and domain ownership.

View File

@ -0,0 +1,22 @@
# Authentication state and recovery
`AuthProvider` publishes the query owner: user ID, confirmed logout (`null`), or
unknown auth (`undefined`). Keep auth outside `QueryClientProvider`. Its mount-local
client remounts consumers, clears the old cache and cancels queries on owner changes.
Confirmed logout unmounts consumers immediately; public/unknown routes retain an
independent client. Superseded refreshes cannot restore an earlier owner.
Transport failure leaves unknown auth eligible for the 60-second visible-tab
check. A visible `online` event revalidates unknown auth immediately, but never
authenticates on connectivity alone. Confirmed anonymous/logout and static demos
do not probe. Keep wakeups single-flight and remove listeners on unmount.
Only the current request may release its in-flight guard; confirmed user/logout
supersedes that ownership. Coalesce reconnects during a pending refresh into one
follow-up only if auth remains unknown. Success, 401/logout, account replacement
and unmount discard the deferred wake. Hidden tabs retain reconnect wakeups until
visible, bypassing the tab-switch throttle once. Keep `isLoading` in the effect
dependencies so failed requests that leave auth unchanged still drain pending wakes.
Regressions: `tests/unit/core/auth/AuthProvider-recovery.dom.test.tsx` and
`tests/e2e/auth-network-recovery.spec.ts` under `frontend/`, alongside query-owner
and artifact-draft isolation suites. Forms trim emails, never raw passwords.

View File

@ -76,6 +76,8 @@ export function AuthProvider({
const [isLoading, setIsLoading] = useState(false);
// Logout and externally confirmed users supersede pending auth refreshes.
const authRequestRef = React.useRef(0);
const authRefreshInFlightRef = React.useRef(false);
const reconnectPendingRef = React.useRef(false);
const router = useRouter();
const pathname = usePathname();
const staticMode = isStaticWebsiteOnly();
@ -89,6 +91,8 @@ export function AuthProvider({
*/
const applyUser = useCallback((next: User | null) => {
authRequestRef.current++;
authRefreshInFlightRef.current = false;
reconnectPendingRef.current = false;
setUser(next);
setAuthKnown(true);
setIsLoading(false);
@ -101,6 +105,7 @@ export function AuthProvider({
const refreshUser = useCallback(async () => {
if (staticMode) return;
const requestId = ++authRequestRef.current;
authRefreshInFlightRef.current = true;
try {
setIsLoading(true);
@ -139,7 +144,10 @@ export function AuthProvider({
setUser(null);
setAuthKnown(false);
} finally {
if (requestId === authRequestRef.current) setIsLoading(false);
if (requestId === authRequestRef.current) {
authRefreshInFlightRef.current = false;
setIsLoading(false);
}
}
}, [staticMode, pathname, router]);
@ -157,6 +165,8 @@ export function AuthProvider({
const logout = useCallback(async () => {
// Immediately clear local state to prevent UI flicker
authRequestRef.current++;
authRefreshInFlightRef.current = false;
reconnectPendingRef.current = false;
setUser(null);
setAuthKnown(true);
setIsLoading(false);
@ -190,27 +200,54 @@ export function AuthProvider({
}, [staticMode, router]);
/**
* Handle visibility change - refresh user when tab becomes visible again.
* Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches.
* Revalidate visible tabs, including unknown auth after a transport failure.
* Tab switches are throttled to 60 s; reconnect wakes unknown auth immediately.
*/
const lastCheckRef = React.useRef(0);
useEffect(
() => () => {
reconnectPendingRef.current = false;
},
[],
);
useEffect(() => {
if (staticMode) return;
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible" || user === null) return;
const checkUser = (reconnecting = false) => {
if (user === null && authKnown) return;
if (reconnecting) reconnectPendingRef.current = true;
if (
document.visibilityState !== "visible" ||
authRefreshInFlightRef.current
)
return;
const now = Date.now();
if (now - lastCheckRef.current < 60_000) return;
if (!reconnectPendingRef.current && now - lastCheckRef.current < 60_000)
return;
reconnectPendingRef.current = false;
lastCheckRef.current = now;
void refreshUser();
};
const handleVisibilityChange = () => checkUser();
const handleOnline = () => {
if (!authKnown || authRefreshInFlightRef.current) checkUser(true);
};
// isLoading re-runs this effect when a failed request leaves auth unchanged.
if (reconnectPendingRef.current && !authRefreshInFlightRef.current) {
if (authKnown) reconnectPendingRef.current = false;
else checkUser();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("online", handleOnline);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("online", handleOnline);
};
}, [staticMode, user, refreshUser]);
}, [staticMode, user, authKnown, isLoading, refreshUser]);
const value: AuthContextType = {
user,

View File

@ -0,0 +1,76 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
for (const recoveryEvent of [
"online",
"visibilitychange",
"hidden reconnect",
] as const) {
test(`authentication recovers after a network failure on ${recoveryEvent}`, async ({
page,
}) => {
mockLangGraphAPI(page);
await page.clock.install();
let requests = 0;
await page.route("**/api/v1/auth/me", async (route) => {
requests++;
if (requests === 1) {
await route.abort("internetdisconnected");
return;
}
await route.fulfill({
json: {
id: "default",
email: "recovered@test.local",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
},
});
});
await page.goto("/workspace/chats/new?settings=account");
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
await expect(dialog.getByText("default@test.local")).toBeVisible();
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect.poll(() => requests).toBe(1);
await expect(dialog.getByText("default@test.local")).toBeHidden();
await expect(page).not.toHaveURL(/\/login/);
if (recoveryEvent === "hidden reconnect") {
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
window.dispatchEvent(new Event("online"));
});
expect(requests).toBe(1);
await expect(dialog.getByText("recovered@test.local")).toBeHidden();
await page.clock.fastForward(30_000);
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
});
} else {
if (recoveryEvent === "visibilitychange") {
await page.clock.fastForward(60_001);
}
await page.evaluate((event) => {
if (event === "online") window.dispatchEvent(new Event(event));
else document.dispatchEvent(new Event(event));
}, recoveryEvent);
}
await expect(dialog.getByText("recovered@test.local")).toBeVisible();
expect(requests).toBe(2);
await expect(page).not.toHaveURL(/\/login/);
});
}

View File

@ -0,0 +1,297 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { act, cleanup, render, screen } from "@testing-library/react";
import { useContext } from "react";
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider";
import { AuthQueryScopeContext } from "@/core/auth/query-scope";
import type { User } from "@/core/auth/types";
import { isStaticWebsiteOnly } from "@/core/static-mode";
const push = rs.fn();
const router = { push };
rs.mock("next/navigation", () => ({
useRouter: () => router,
usePathname: () => "/workspace/chats/example",
}));
rs.mock("@/core/static-mode", () => ({
isStaticWebsiteOnly: rs.fn(() => false),
}));
const user: User = {
id: "account-a",
email: "a@example.com",
system_role: "user",
needs_setup: false,
oauth_provider: null,
};
let auth: ReturnType<typeof useAuth>;
let owner: string | null | undefined;
function Probe() {
auth = useAuth();
owner = useContext(AuthQueryScopeContext);
return <output>{auth.user?.email ?? "No confirmed user"}</output>;
}
function mount(initialUser: User | null = user, initialAuthUnknown = false) {
return render(
<AuthProvider
initialUser={initialUser}
initialAuthUnknown={initialAuthUnknown}
>
<Probe />
</AuthProvider>,
);
}
function userResponse(next: User = user) {
return new Response(JSON.stringify(next), {
headers: { "Content-Type": "application/json" },
});
}
async function wake(event: "online" | "visibilitychange") {
await act(async () => {
if (event === "online") window.dispatchEvent(new Event(event));
else document.dispatchEvent(new Event(event));
});
}
beforeEach(() => {
rs.mocked(isStaticWebsiteOnly).mockReturnValue(false);
rs.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
rs.spyOn(Date, "now").mockReturnValue(100_000);
rs.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
push.mockReset();
});
test.each(["online", "visibilitychange"] as const)(
"%s recovers unknown auth after a failed visible-tab refresh",
async (event) => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(userResponse());
mount();
await wake("visibilitychange");
expect(fetch).toHaveBeenCalledTimes(1);
expect(auth.user).toBeNull();
expect(owner).toBeUndefined();
expect(push).not.toHaveBeenCalled();
// Reconnect must not wait for the failed visibility check's throttle.
rs.mocked(Date.now).mockReturnValue(event === "online" ? 100_001 : 160_001);
await wake(event);
expect(screen.getByText("a@example.com")).toBeTruthy();
expect(owner).toBe("account-a");
expect(fetch).toHaveBeenCalledTimes(2);
expect(push).not.toHaveBeenCalled();
},
);
test("reconnect events do not overlap an in-flight unknown-auth check", async () => {
let resolve!: (response: Response) => void;
const fetch = rs
.spyOn(globalThis, "fetch")
.mockImplementation(
() => new Promise<Response>((done) => (resolve = done)),
);
mount(null, true);
await act(async () => {
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("online"));
document.dispatchEvent(new Event("visibilitychange"));
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(auth.user).toBeNull();
expect(owner).toBeUndefined();
await act(async () => resolve(userResponse()));
expect(screen.getByText("a@example.com")).toBeTruthy();
expect(owner).toBe("account-a");
expect(fetch).toHaveBeenCalledTimes(1);
});
test.each([false, true])(
"coalesces a reconnect received before a failed refresh settles (initially unknown: %s)",
async (initiallyUnknown) => {
let reject!: (reason: Error) => void;
const fetch = rs
.spyOn(globalThis, "fetch")
.mockImplementationOnce(
() => new Promise<Response>((_resolve, fail) => (reject = fail)),
)
.mockResolvedValueOnce(userResponse());
mount(initiallyUnknown ? null : user, initiallyUnknown);
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("online"));
});
expect(fetch).toHaveBeenCalledTimes(1);
await act(async () => reject(new TypeError("Failed to fetch")));
expect(screen.getByText("a@example.com")).toBeTruthy();
expect(owner).toBe("account-a");
expect(fetch).toHaveBeenCalledTimes(2);
},
);
test("unknown-auth recovery retains the visible-tab throttle", async () => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockRejectedValue(new TypeError("Failed to fetch"));
mount();
await wake("visibilitychange");
rs.mocked(Date.now).mockReturnValue(100_001);
await wake("visibilitychange");
expect(fetch).toHaveBeenCalledTimes(1);
});
test.each(["401", "logout", "account", "unmount"] as const)(
"%s cancels a queued reconnect wakeup",
async (action) => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => (resolve = done));
const fetch = rs
.spyOn(globalThis, "fetch")
.mockImplementation((input) =>
input === "/api/v1/auth/me"
? pending
: Promise.resolve(new Response(null, { status: 204 })),
);
const view = mount(null, true);
await wake("visibilitychange");
await wake("online");
await act(async () => {
if (action === "logout") await auth.logout();
if (action === "account") auth.applyUser({ ...user, id: "account-b" });
if (action === "unmount") view.unmount();
resolve(
new Response(JSON.stringify({ detail: "expired" }), { status: 401 }),
);
});
expect(
fetch.mock.calls.filter(([url]) => url === "/api/v1/auth/me"),
).toHaveLength(1);
if (action !== "unmount") {
expect(owner).toBe(action === "account" ? "account-b" : null);
}
},
);
test.each([false, true])(
"confirmed anonymous sessions do not restart checks (explicit logout: %s)",
async (logout) => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 204 }));
mount(logout ? user : null);
if (logout) await act(async () => auth.logout());
fetch.mockClear();
await wake("online");
await wake("visibilitychange");
expect(fetch).not.toHaveBeenCalled();
expect(owner).toBeNull();
},
);
test("a recovered 401 remains a confirmed logout", async () => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ detail: "expired" }), { status: 401 }),
);
mount(null, true);
await wake("online");
expect(owner).toBeNull();
expect(push).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledWith(expect.stringContaining("/login?next="));
await wake("online");
expect(fetch).toHaveBeenCalledTimes(1);
});
test("a hidden-tab reconnect bypasses the failed visible-tab throttle", async () => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(userResponse());
const visibility = rs.spyOn(document, "visibilityState", "get");
mount();
await wake("visibilitychange");
expect(auth.user).toBeNull();
expect(owner).toBeUndefined();
visibility.mockReturnValue("hidden");
rs.mocked(Date.now).mockReturnValue(100_001);
await wake("online");
await wake("online");
await wake("visibilitychange");
expect(fetch).toHaveBeenCalledTimes(1);
expect(auth.user).toBeNull();
visibility.mockReturnValue("visible");
rs.mocked(Date.now).mockReturnValue(130_000);
await wake("visibilitychange");
expect(screen.getByText("a@example.com")).toBeTruthy();
expect(owner).toBe("account-a");
await wake("visibilitychange");
expect(fetch).toHaveBeenCalledTimes(2);
expect(push).not.toHaveBeenCalled();
});
test("a reconnect survives a failed refresh settling while hidden", async () => {
let reject!: (reason: Error) => void;
const fetch = rs
.spyOn(globalThis, "fetch")
.mockImplementationOnce(
() => new Promise<Response>((_resolve, fail) => (reject = fail)),
)
.mockResolvedValueOnce(userResponse());
const visibility = rs.spyOn(document, "visibilityState", "get");
mount(null, true);
await wake("visibilitychange");
await wake("online");
visibility.mockReturnValue("hidden");
await act(async () => reject(new TypeError("Failed to fetch")));
expect(fetch).toHaveBeenCalledTimes(1);
expect(auth.user).toBeNull();
expect(owner).toBeUndefined();
visibility.mockReturnValue("visible");
rs.mocked(Date.now).mockReturnValue(130_000);
await wake("visibilitychange");
expect(screen.getByText("a@example.com")).toBeTruthy();
expect(owner).toBe("account-a");
expect(fetch).toHaveBeenCalledTimes(2);
expect(push).not.toHaveBeenCalled();
});
test("static demos do not probe even when auth starts unknown", async () => {
rs.mocked(isStaticWebsiteOnly).mockReturnValue(true);
const fetch = rs.spyOn(globalThis, "fetch");
mount(null, true);
await wake("online");
await wake("visibilitychange");
expect(fetch).not.toHaveBeenCalled();
});
test("unmount removes automatic auth wakeups", async () => {
const fetch = rs.spyOn(globalThis, "fetch");
const view = mount(null, true);
view.unmount();
await wake("online");
await wake("visibilitychange");
expect(fetch).not.toHaveBeenCalled();
});