From d4c8fc9db8134b600124ae67d38fe770503d5fd0 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 4 Nov 2026 14:34:18 +0800 Subject: [PATCH] fix(auth): preserve intentional logout across browser return Keep a bounded per-tab logout intent and broadcast it to already open tabs so back navigation after a normal logout is not mistaken for renewal failure. Preserve real refresh and session expiry signals, and keep IdP navigation reachable when local cleanup fails. --- src/app/RefineContext.test.tsx | 65 +++++++++++++++++++++++++++ src/app/RefineContext.tsx | 9 +++- src/lib/logoutFlow.test.ts | 25 +++++++++++ src/lib/logoutFlow.ts | 9 +++- src/lib/logoutIntent.test.ts | 57 ++++++++++++++++++++++++ src/lib/logoutIntent.ts | 81 ++++++++++++++++++++++++++++++++++ src/store/authStore.test.ts | 45 +++++++++++++++++++ src/store/authStore.ts | 22 ++++++--- 8 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 src/lib/logoutIntent.test.ts create mode 100644 src/lib/logoutIntent.ts create mode 100644 src/store/authStore.test.ts 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,