1
0
forked from baron/baron-sso

fix: resolve admin session infinite reload loop and sync auth state

- Prevent infinite redirection loop by clearing oidc-client user state on 401 errors.
- Sync apiClient request interceptor to use userManager.getUser() for reliable token retrieval.
- Add extensive console logs for better session issue diagnosis.
- Fix TS error in LoginPage by updating button variant.
- Revert 'ae03fe1' (updated playwright fixtures to real domain) as requested.
This commit is contained in:
2026-04-21 17:06:03 +09:00
parent ae03fe1475
commit 4427ab1f85
13 changed files with 119 additions and 52 deletions

View File

@@ -80,9 +80,19 @@ function AppLayout() {
};
}, []);
const { data: profile } = useQuery({
const { data: profile, isLoading: isProfileLoading, error: profileError } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
queryFn: async () => {
console.debug("[AppLayout] Fetching profile...");
try {
const data = await fetchMe();
console.debug("[AppLayout] Profile fetched successfully:", data.email);
return data;
} catch (err) {
console.error("[AppLayout] Failed to fetch profile:", err);
throw err;
}
},
enabled:
(auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development" ||
@@ -170,7 +180,15 @@ function AppLayout() {
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
console.debug("[AppLayout] Auth state check:", {
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
isTest
});
if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
console.warn("[AppLayout] Not authenticated, redirecting to /login");
navigate("/login");
}
}, [auth.isLoading, auth.isAuthenticated, navigate]);

View File

@@ -8,6 +8,11 @@ function AuthCallbackPage() {
const navigate = useNavigate();
useEffect(() => {
console.debug("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error
});
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
@@ -21,12 +26,13 @@ function AuthCallbackPage() {
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
console.info("[AuthCallbackPage] Auth successful, navigating to", returnTo);
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user]);
}, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">

View File

@@ -20,10 +20,16 @@ function LoginPage() {
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
console.debug("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo
});
if (auth.isAuthenticated) {
console.info("[LoginPage] User is authenticated, redirecting to", returnTo);
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
}, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => {
if (!shouldAutoLogin) {
@@ -72,8 +78,8 @@ function LoginPage() {
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
variant="link"
className="p-0 h-auto text-destructive underline mt-2"
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
window.location.href =
window.location.origin + window.location.pathname;

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
@@ -7,14 +8,16 @@ const apiClient = axios.create({
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
});
apiClient.interceptors.request.use((config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
const sessionToken = window.localStorage.getItem("admin_session");
apiClient.interceptors.request.use(async (config) => {
// IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다.
const user = await userManager.getUser();
const sessionToken = user?.access_token || window.localStorage.getItem("admin_session");
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
// 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
@@ -33,10 +36,24 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
if (error.response?.status === 401) {
console.warn("[apiClient] 401 Unauthorized detected. Clearing session state.");
// 로컬 스토리지의 세션 키 제거
window.localStorage.removeItem("admin_session");
window.location.href = "/login";
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
await userManager.removeUser();
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
if (!isAuthPath && !isLoginPath) {
console.info("[apiClient] Redirecting to /login from", window.location.pathname);
window.location.href = "/login";
}
}
return Promise.reject(error);
},

View File

@@ -29,18 +29,28 @@ export function shouldAttemptSlidingSessionRenew({
}
if (typeof expiresAtSec !== "number") {
console.debug("[sessionSliding] expiresAtSec is not a number, skipping renew");
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug("[sessionSliding] Session already expired, skipping renew");
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling renewal attempt");
return false;
}
console.info(`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`);
return true;
}
@@ -60,17 +70,27 @@ export function shouldAttemptUnlimitedSessionRenew({
}
if (typeof expiresAtSec !== "number") {
console.debug("[sessionSliding] expiresAtSec is not a number, skipping unlimited renew");
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug("[sessionSliding] Session already expired, skipping unlimited renew");
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
return false;
}
console.info(`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`);
return true;
}

View File

@@ -10,7 +10,7 @@ test.describe("Authentication", () => {
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -45,12 +45,12 @@ test.describe("Authentication", () => {
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "https://sso.hmac.kr/oidc",
authorization_endpoint: "https://sso.hmac.kr/oidc/auth",
token_endpoint: "https://sso.hmac.kr/oidc/token",
jwks_uri: "https://sso.hmac.kr/oidc/jwks",
userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo",
end_session_endpoint: "https://sso.hmac.kr/oidc/session/end",
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});

View File

@@ -9,7 +9,7 @@ test.describe("Bulk Actions and Tree Search", () => {
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -114,7 +114,7 @@ test.describe("Bulk Actions and Tree Search", () => {
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});

View File

@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Tenant Owners Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -26,7 +26,7 @@ test.describe("Tenant Owners Management", () => {
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {

View File

@@ -9,7 +9,7 @@ test.describe("Tenants Management", () => {
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -52,7 +52,7 @@ test.describe("Tenants Management", () => {
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});

View File

@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -33,15 +33,15 @@ test.describe("User Management", () => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "https://sso.hmac.kr/oidc",
authorization_endpoint: "https://sso.hmac.kr/oidc/auth",
token_endpoint: "https://sso.hmac.kr/oidc/token",
userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo",
jwks_uri: "https://sso.hmac.kr/oidc/jwks",
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {

View File

@@ -9,7 +9,7 @@ test.describe("Users Bulk Upload", () => {
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -54,7 +54,7 @@ test.describe("Users Bulk Upload", () => {
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});

View File

@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("User Schema Dynamic Form", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "https://sso.hmac.kr/oidc";
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
@@ -35,15 +35,15 @@ test.describe("User Schema Dynamic Form", () => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "https://sso.hmac.kr/oidc",
authorization_endpoint: "https://sso.hmac.kr/oidc/auth",
token_endpoint: "https://sso.hmac.kr/oidc/token",
userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo",
jwks_uri: "https://sso.hmac.kr/oidc/jwks",
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } });
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {

View File

@@ -137,11 +137,11 @@ export async function seedAuth(page: Page, role?: string) {
};
window.localStorage.setItem(
"oidc.user:https://sso.hmac.kr/oidc:devfront",
"oidc.user:http://localhost:5000/oidc:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:https://sso.hmac.kr/oidc/:devfront",
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
@@ -155,12 +155,12 @@ export async function seedAuth(page: Page, role?: string) {
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "https://sso.hmac.kr/oidc",
authorization_endpoint: "https://sso.hmac.kr/oidc/auth",
token_endpoint: "https://sso.hmac.kr/oidc/token",
jwks_uri: "https://sso.hmac.kr/oidc/jwks",
userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo",
end_session_endpoint: "https://sso.hmac.kr/oidc/session/end",
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});