fix(auth): align session lifecycle with Keycloak
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { resolveRequestUrl } from "@/lib/api";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
describe("resolveRequestUrl", () => {
|
||||
it("does not prepend baseURL to an absolute request URL", () => {
|
||||
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
|
||||
).toBe("http://localhost:8000/api/v1/schemes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication lifecycle state", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
accessToken: "access-token",
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears only the local access token when a request becomes unauthorized", () => {
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-8
@@ -1,6 +1,5 @@
|
||||
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
||||
import { config } from "@config/config";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import {
|
||||
applyAuthContextHeaders,
|
||||
@@ -13,8 +12,6 @@ export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
let isSigningOut = false;
|
||||
|
||||
export const resolveRequestUrl = (request: {
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
@@ -63,11 +60,7 @@ api.interceptors.response.use(
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||
useAuthStore.getState().setAccessToken(null);
|
||||
if (!isSigningOut) {
|
||||
isSigningOut = true;
|
||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
||||
}
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
|
||||
+1
-8
@@ -1,12 +1,9 @@
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import {
|
||||
applyAuthContextHeaders,
|
||||
type AuthContextHeaderOptions,
|
||||
} from "@/lib/requestHeaders";
|
||||
|
||||
let isSigningOut = false;
|
||||
|
||||
const unwrapPage = async (response: Response) => {
|
||||
if (
|
||||
!response.headers.get("content-type")?.includes("application/json")
|
||||
@@ -58,11 +55,7 @@ export const apiFetch = async (
|
||||
const response = await fetch(input, requestInit);
|
||||
|
||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||
useAuthStore.getState().setAccessToken(null);
|
||||
if (!isSigningOut) {
|
||||
isSigningOut = true;
|
||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
||||
}
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
}
|
||||
|
||||
return unwrapPage(response);
|
||||
|
||||
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
|
||||
setAccessToken(null);
|
||||
}
|
||||
const session = await getSession();
|
||||
if (session?.error) {
|
||||
return null;
|
||||
}
|
||||
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
||||
if (token && !isTokenExpired(token)) {
|
||||
setAccessToken(token);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const DraftFixture = ({ initial }: { initial: string }) => {
|
||||
const [value, setValue] = useState(initial);
|
||||
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
|
||||
return <output>{value}</output>;
|
||||
};
|
||||
|
||||
describe("useSessionRecoveryDraft", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves an in-progress value when authentication expires and restores it once", () => {
|
||||
const first = render(<DraftFixture initial="in-progress" />);
|
||||
|
||||
act(() => {
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
|
||||
JSON.stringify({ value: "in-progress" }),
|
||||
);
|
||||
first.unmount();
|
||||
|
||||
useAuthStore.getState().clearSessionExpired();
|
||||
const restored = render(<DraftFixture initial="empty" />);
|
||||
|
||||
expect(restored.getByText("in-progress")).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const STORAGE_PREFIX = "tjwater-session-recovery:";
|
||||
|
||||
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
|
||||
|
||||
export const clearSessionRecoveryDrafts = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const useSessionRecoveryDraft = <T,>(
|
||||
key: string,
|
||||
value: T,
|
||||
restore: (value: T) => void,
|
||||
) => {
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredRef.current || typeof window === "undefined") return;
|
||||
restoredRef.current = true;
|
||||
const raw = sessionStorage.getItem(storageKey(key));
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
restore(JSON.parse(raw) as T);
|
||||
sessionStorage.removeItem(storageKey(key));
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey(key));
|
||||
}
|
||||
}, [key, restore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionExpired || typeof window === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||
} catch {
|
||||
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
|
||||
}
|
||||
}, [key, sessionExpired, value]);
|
||||
};
|
||||
Reference in New Issue
Block a user