diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx
index a755f5a..d02be92 100644
--- a/src/app/RefineContext.test.tsx
+++ b/src/app/RefineContext.test.tsx
@@ -3,12 +3,17 @@ import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
+import {
+ clearIntentionalLogout,
+ markIntentionalLogout,
+} from "@/lib/logoutIntent";
import { App } from "./RefineContext";
let mockSessionState: {
data: {
accessToken: string;
user: { id: string; name: string };
+ error?: "RefreshAccessTokenError" | "SessionExpired";
} | null;
status: "authenticated" | "unauthenticated";
} = {
@@ -70,6 +75,7 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request;
beforeEach(() => {
+ clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
mockSessionState = {
@@ -210,4 +216,63 @@ describe("RefineContext access authentication", () => {
});
});
});
+
+ it("does not treat returning to a protected route after logout as session expiry", async () => {
+ markIntentionalLogout();
+ mockSessionState = { data: null, status: "unauthenticated" };
+ useAuthStore.setState({
+ accessToken: null,
+ sessionExpired: true,
+ sessionExpiryReason: "refresh_failed",
+ });
+
+ render(
+
+ 应用内容
+ ,
+ );
+
+ await waitFor(() => {
+ expect(useAuthStore.getState()).toMatchObject({
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ });
+ });
+
+ it("does not hide a real refresh failure after an intentional logout", async () => {
+ markIntentionalLogout();
+ mockSessionState = {
+ data: {
+ accessToken: "expired-access-token",
+ user: { id: "user-1", name: "Test User" },
+ error: "RefreshAccessTokenError",
+ },
+ status: "authenticated",
+ };
+
+ const { rerender } = render(
+
+ 应用内容
+ ,
+ );
+
+ await waitFor(() => {
+ expect(useAuthStore.getState()).toMatchObject({
+ sessionExpired: true,
+ sessionExpiryReason: "refresh_failed",
+ });
+ });
+
+ mockSessionState = { data: null, status: "unauthenticated" };
+ rerender(
+
+ 应用内容
+ ,
+ );
+
+ await waitFor(() => {
+ expect(useAuthStore.getState().sessionExpired).toBe(true);
+ });
+ });
});
diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx
index 8ebe24c..e835ed9 100644
--- a/src/app/RefineContext.tsx
+++ b/src/app/RefineContext.tsx
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
+import {
+ clearIntentionalLogout,
+ isIntentionalLogout,
+} from "@/lib/logoutIntent";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
@@ -88,15 +92,17 @@ export const App = (props: React.PropsWithChildren) => {
useEffect(() => {
if (data?.error === "SessionExpired") {
+ clearIntentionalLogout();
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
+ clearIntentionalLogout();
markSessionExpired("refresh_failed");
return;
}
if (status === "unauthenticated") {
- if (to === "/login") {
+ if (to === "/login" || isIntentionalLogout()) {
clearSessionExpired();
return;
}
@@ -104,6 +110,7 @@ export const App = (props: React.PropsWithChildren) => {
return;
}
if (status === "authenticated") {
+ clearIntentionalLogout();
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
diff --git a/src/lib/logoutFlow.test.ts b/src/lib/logoutFlow.test.ts
index 3cbaccb..748093d 100644
--- a/src/lib/logoutFlow.test.ts
+++ b/src/lib/logoutFlow.test.ts
@@ -1,4 +1,8 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
+import {
+ clearIntentionalLogout,
+ isIntentionalLogout,
+} from "./logoutIntent";
describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => {
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
});
describe("completeLogout", () => {
+ beforeEach(() => {
+ clearIntentionalLogout();
+ });
+
it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn(
() => new Promise(() => undefined),
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1);
+ expect(isIntentionalLogout()).toBe(true);
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true });
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
+
+ it("still navigates when local cleanup throws", async () => {
+ const navigate = jest.fn();
+
+ await expect(
+ completeLogout({
+ reportAudit: jest.fn().mockResolvedValue(undefined),
+ clearLocalState: () => {
+ throw new DOMException("Storage unavailable", "SecurityError");
+ },
+ navigate,
+ }),
+ ).resolves.toEqual({ success: true });
+
+ expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
+ });
});
diff --git a/src/lib/logoutFlow.ts b/src/lib/logoutFlow.ts
index b3c33f6..1df123b 100644
--- a/src/lib/logoutFlow.ts
+++ b/src/lib/logoutFlow.ts
@@ -1,3 +1,5 @@
+import { markIntentionalLogout } from "@/lib/logoutIntent";
+
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
type CompleteLogoutOptions = {
@@ -41,7 +43,12 @@ export const completeLogout = async ({
// Synchronous reporting errors must not block logout either.
}
- clearLocalState();
+ markIntentionalLogout();
+ try {
+ clearLocalState();
+ } catch {
+ // Browser storage cleanup failures must not prevent the IdP logout.
+ }
navigate(KEYCLOAK_LOGOUT_PATH);
return { success: true as const };
};
diff --git a/src/lib/logoutIntent.test.ts b/src/lib/logoutIntent.test.ts
new file mode 100644
index 0000000..a33e3c8
--- /dev/null
+++ b/src/lib/logoutIntent.test.ts
@@ -0,0 +1,57 @@
+import {
+ clearIntentionalLogout,
+ isIntentionalLogout,
+ markIntentionalLogout,
+} from "./logoutIntent";
+
+describe("intentional logout marker", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ clearIntentionalLogout();
+ });
+
+ it("stores a timestamp only for the current tab", () => {
+ jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+
+ markIntentionalLogout();
+
+ expect(sessionStorage.getItem("tjwater:intentional-logout")).toBe(
+ "1700000000000",
+ );
+ expect(localStorage.getItem("tjwater:intentional-logout")).toBeNull();
+ expect(isIntentionalLogout()).toBe(true);
+ });
+
+ it("accepts an intentional logout event from another open tab", () => {
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "tjwater:intentional-logout-event",
+ newValue: JSON.stringify({
+ type: "mark",
+ markedAt: Date.now(),
+ }),
+ }),
+ );
+
+ expect(isIntentionalLogout()).toBe(true);
+ });
+
+ it("does not broadcast a clear event when the current tab has no marker", () => {
+ clearIntentionalLogout();
+ const setItem = jest.spyOn(Storage.prototype, "setItem");
+
+ expect(isIntentionalLogout()).toBe(false);
+ expect(setItem).not.toHaveBeenCalled();
+ });
+
+ it("expires a stale marker", () => {
+ const now = 1_700_000_000_000;
+ const nowSpy = jest.spyOn(Date, "now").mockReturnValue(now);
+ markIntentionalLogout();
+
+ nowSpy.mockReturnValue(now + 30 * 60 * 1000 + 1);
+
+ expect(isIntentionalLogout()).toBe(false);
+ expect(sessionStorage.getItem("tjwater:intentional-logout")).toBeNull();
+ });
+});
diff --git a/src/lib/logoutIntent.ts b/src/lib/logoutIntent.ts
new file mode 100644
index 0000000..adf6b2f
--- /dev/null
+++ b/src/lib/logoutIntent.ts
@@ -0,0 +1,81 @@
+const INTENTIONAL_LOGOUT_STORAGE_KEY = "tjwater:intentional-logout";
+const INTENTIONAL_LOGOUT_EVENT_KEY = "tjwater:intentional-logout-event";
+const INTENTIONAL_LOGOUT_TTL_MS = 30 * 60 * 1000;
+let memoryMarkedAt: number | null = null;
+
+const withStorage = (
+ storageName: "localStorage" | "sessionStorage",
+ callback: (storage: Storage) => void,
+) => {
+ if (typeof window === "undefined") return;
+ try {
+ callback(window[storageName]);
+ } catch {
+ // Logout must still proceed when browser storage is unavailable.
+ }
+};
+
+const setLocalMarker = (markedAt: number | null) => {
+ memoryMarkedAt = markedAt;
+ withStorage("sessionStorage", (storage) => {
+ if (markedAt === null) {
+ storage.removeItem(INTENTIONAL_LOGOUT_STORAGE_KEY);
+ } else {
+ storage.setItem(INTENTIONAL_LOGOUT_STORAGE_KEY, String(markedAt));
+ }
+ });
+};
+
+const broadcastIntent = (
+ payload: { type: "mark"; markedAt: number } | { type: "clear" },
+) => {
+ withStorage("localStorage", (storage) => {
+ storage.setItem(INTENTIONAL_LOGOUT_EVENT_KEY, JSON.stringify(payload));
+ storage.removeItem(INTENTIONAL_LOGOUT_EVENT_KEY);
+ });
+};
+
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (event.key !== INTENTIONAL_LOGOUT_EVENT_KEY || !event.newValue) return;
+ try {
+ const payload = JSON.parse(event.newValue) as {
+ type?: unknown;
+ markedAt?: unknown;
+ };
+ if (payload.type === "mark" && typeof payload.markedAt === "number") {
+ setLocalMarker(payload.markedAt);
+ } else if (payload.type === "clear") {
+ setLocalMarker(null);
+ }
+ } catch {
+ // Ignore malformed cross-tab events.
+ }
+ });
+}
+
+export const markIntentionalLogout = () => {
+ const markedAt = Date.now();
+ setLocalMarker(markedAt);
+ broadcastIntent({ type: "mark", markedAt });
+};
+
+export const clearIntentionalLogout = () => {
+ setLocalMarker(null);
+ broadcastIntent({ type: "clear" });
+};
+
+export const isIntentionalLogout = () => {
+ let markedAt = memoryMarkedAt;
+ withStorage("sessionStorage", (storage) => {
+ const stored = Number(storage.getItem(INTENTIONAL_LOGOUT_STORAGE_KEY));
+ if (Number.isFinite(stored) && stored > 0) markedAt = stored;
+ });
+ if (markedAt === null) return false;
+ const age = Date.now() - markedAt;
+ if (age < 0 || age > INTENTIONAL_LOGOUT_TTL_MS) {
+ setLocalMarker(null);
+ return false;
+ }
+ return true;
+};
diff --git a/src/store/authStore.test.ts b/src/store/authStore.test.ts
new file mode 100644
index 0000000..b00f06a
--- /dev/null
+++ b/src/store/authStore.test.ts
@@ -0,0 +1,45 @@
+import {
+ clearIntentionalLogout,
+ markIntentionalLogout,
+} from "@/lib/logoutIntent";
+
+import { useAuthStore } from "./authStore";
+
+describe("authStore intentional logout boundary", () => {
+ beforeEach(() => {
+ clearIntentionalLogout();
+ useAuthStore.setState({
+ accessToken: "access-token",
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ });
+
+ afterEach(() => {
+ clearIntentionalLogout();
+ });
+
+ it("ignores late expiry signals after an intentional logout", () => {
+ markIntentionalLogout();
+
+ useAuthStore.getState().markSessionExpired("unauthorized");
+
+ expect(useAuthStore.getState()).toMatchObject({
+ accessToken: null,
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ });
+
+ it("keeps real refresh failures visible after an intentional logout", () => {
+ markIntentionalLogout();
+
+ useAuthStore.getState().markSessionExpired("refresh_failed");
+
+ expect(useAuthStore.getState()).toMatchObject({
+ accessToken: null,
+ sessionExpired: true,
+ sessionExpiryReason: "refresh_failed",
+ });
+ });
+});
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 64e2561..371cc50 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -1,5 +1,7 @@
import { create } from "zustand";
+import { isIntentionalLogout } from "@/lib/logoutIntent";
+
interface AuthState {
accessToken: string | null;
sessionExpired: boolean;
@@ -16,11 +18,21 @@ export const useAuthStore = create((set) => ({
sessionExpired: false,
sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }),
- markSessionExpired: (reason) => set({
- accessToken: null,
- sessionExpired: true,
- sessionExpiryReason: reason,
- }),
+ markSessionExpired: (reason) => {
+ if (reason === "unauthorized" && isIntentionalLogout()) {
+ set({
+ accessToken: null,
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ return;
+ }
+ set({
+ accessToken: null,
+ sessionExpired: true,
+ sessionExpiryReason: reason,
+ });
+ },
clearSessionExpired: () => set({
sessionExpired: false,
sessionExpiryReason: null,