fix(auth): prevent logout audit from blocking redirect
This commit is contained in:
+19
-19
@@ -22,6 +22,7 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
||||||
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
@@ -153,25 +154,24 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: () =>
|
||||||
try {
|
completeLogout({
|
||||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
reportAudit: () =>
|
||||||
method: "POST",
|
reportLogoutAudit({
|
||||||
headers: { "Content-Type": "application/json" },
|
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
||||||
body: JSON.stringify({ event: "logout" }),
|
accessToken:
|
||||||
projectHeaderMode: "omit",
|
typeof data?.accessToken === "string"
|
||||||
skipAuthRedirect: true,
|
? data.accessToken
|
||||||
});
|
: useAuthStore.getState().accessToken,
|
||||||
} catch {
|
}),
|
||||||
// Logout must still complete when audit storage is unavailable.
|
clearLocalState: () => {
|
||||||
}
|
if (data?.user?.id) {
|
||||||
if (data?.user?.id) {
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
}
|
||||||
}
|
clearSessionRecoveryDrafts();
|
||||||
clearSessionRecoveryDrafts();
|
},
|
||||||
window.location.assign("/api/auth/keycloak-logout");
|
navigate: (path) => window.location.assign(path),
|
||||||
return { success: true };
|
}),
|
||||||
},
|
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
return { error };
|
return { error };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
||||||
|
|
||||||
|
describe("reportLogoutAudit", () => {
|
||||||
|
it("starts an authenticated keepalive request", async () => {
|
||||||
|
const fetcher = jest.fn(async () => ({ ok: true }) as Response);
|
||||||
|
|
||||||
|
await reportLogoutAudit({
|
||||||
|
endpoint: "https://server.example/api/v1/audit-events",
|
||||||
|
accessToken: "access-token",
|
||||||
|
fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetcher.mock.calls[0];
|
||||||
|
expect(url).toBe("https://server.example/api/v1/audit-events");
|
||||||
|
expect(init).toMatchObject({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
expect(new Headers(init?.headers).get("Authorization")).toBe(
|
||||||
|
"Bearer access-token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("completeLogout", () => {
|
||||||
|
it("navigates immediately without waiting for audit reporting", async () => {
|
||||||
|
const reportAudit = jest.fn(
|
||||||
|
() => new Promise<unknown>(() => undefined),
|
||||||
|
);
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
const result = completeLogout({
|
||||||
|
reportAudit,
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(reportAudit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
await expect(result).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes logout when audit reporting fails", async () => {
|
||||||
|
const reportAudit = jest.fn(() => Promise.reject(new Error("offline")));
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeLogout({ reportAudit, clearLocalState, navigate }),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes logout when audit reporting throws synchronously", async () => {
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeLogout({
|
||||||
|
reportAudit: () => {
|
||||||
|
throw new Error("invalid request");
|
||||||
|
},
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
||||||
|
|
||||||
|
type CompleteLogoutOptions = {
|
||||||
|
reportAudit: () => Promise<unknown>;
|
||||||
|
clearLocalState: () => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoutAuditOptions = {
|
||||||
|
endpoint: string;
|
||||||
|
accessToken?: string | null;
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportLogoutAudit = ({
|
||||||
|
endpoint,
|
||||||
|
accessToken,
|
||||||
|
fetcher = fetch,
|
||||||
|
}: LogoutAuditOptions) => {
|
||||||
|
const headers = new Headers({ "Content-Type": "application/json" });
|
||||||
|
if (accessToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetcher(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeLogout = async ({
|
||||||
|
reportAudit,
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
}: CompleteLogoutOptions) => {
|
||||||
|
try {
|
||||||
|
void reportAudit().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Synchronous reporting errors must not block logout either.
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLocalState();
|
||||||
|
navigate(KEYCLOAK_LOGOUT_PATH);
|
||||||
|
return { success: true as const };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user