diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml
index c945a6e1..6289251d 100644
--- a/.gitea/workflows/code_check.yml
+++ b/.gitea/workflows/code_check.yml
@@ -1344,6 +1344,11 @@ jobs:
with:
node-version: "24"
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.5.2
+
- name: Get Playwright version
id: playwright-version
working-directory: devfront
diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png
index c78e6981..e580892d 100644
Binary files a/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png and b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png differ
diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png
index 81710b6c..4e1f643e 100644
Binary files a/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png and b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png differ
diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx
index eba8a863..c93c217c 100644
--- a/devfront/src/app/routes.tsx
+++ b/devfront/src/app/routes.tsx
@@ -43,17 +43,13 @@ export const devFrontRoutes: RouteObject[] = [
},
{
path: "/",
- element:
- import.meta.env.MODE === "development" ? : ,
- children:
- import.meta.env.MODE === "development"
- ? devFrontAppChildren
- : [
- {
- element: ,
- children: devFrontAppChildren,
- },
- ],
+ element: ,
+ children: [
+ {
+ element: ,
+ children: devFrontAppChildren,
+ },
+ ],
},
];
diff --git a/devfront/src/features/auth/AuthCallbackPage.tsx b/devfront/src/features/auth/AuthCallbackPage.tsx
index 1cf9be59..6ea7b459 100644
--- a/devfront/src/features/auth/AuthCallbackPage.tsx
+++ b/devfront/src/features/auth/AuthCallbackPage.tsx
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { userManager } from "../../lib/auth";
+import { isValidOidcSessionUser } from "../../lib/oidcStorage";
export default function AuthCallbackPage() {
const auth = useAuth();
@@ -16,7 +17,7 @@ export default function AuthCallbackPage() {
return;
}
- if (auth.isAuthenticated) {
+ if (auth.isAuthenticated && isValidOidcSessionUser(auth.user)) {
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
@@ -29,7 +30,7 @@ export default function AuthCallbackPage() {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
- }, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
+ }, [auth.isAuthenticated, auth.error, navigate, auth.user]);
return
Loading Auth...
;
}
diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx
index 1f426b6f..d97ff29b 100644
--- a/devfront/src/features/auth/AuthGuard.tsx
+++ b/devfront/src/features/auth/AuthGuard.tsx
@@ -2,22 +2,26 @@ import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom";
import { userManager } from "../../lib/auth";
-import { findPersistedOidcUser } from "../../lib/oidcStorage";
+import {
+ findPersistedOidcUser,
+ isValidOidcSessionUser,
+} from "../../lib/oidcStorage";
export default function AuthGuard() {
const auth = useAuth();
+ const hasActiveAuthUser =
+ auth.isAuthenticated && isValidOidcSessionUser(auth.user);
const [hasStoredUser, setHasStoredUser] = useState(() =>
findPersistedOidcUser() ? true : null,
);
- const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
- ._IS_TEST_MODE === true || navigator.webdriver === true;
+ ._IS_TEST_MODE === true;
useEffect(() => {
let cancelled = false;
- if (isDevelopmentMode || isTestMode) {
+ if (isTestMode) {
setHasStoredUser(true);
return () => {
cancelled = true;
@@ -36,7 +40,7 @@ export default function AuthGuard() {
.getUser()
.then((user) => {
if (!cancelled) {
- setHasStoredUser(Boolean(user && !user.expired));
+ setHasStoredUser(isValidOidcSessionUser(user));
}
})
.catch(() => {
@@ -50,7 +54,7 @@ export default function AuthGuard() {
};
}, [isTestMode]);
- if (isDevelopmentMode || isTestMode) {
+ if (isTestMode) {
return ;
}
@@ -76,7 +80,7 @@ export default function AuthGuard() {
);
}
- if (!auth.isAuthenticated && !hasStoredUser) {
+ if (!hasActiveAuthUser && !hasStoredUser) {
return ;
}
diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx
index b1908eaf..91c8006f 100644
--- a/devfront/src/features/auth/LoginPage.tsx
+++ b/devfront/src/features/auth/LoginPage.tsx
@@ -11,6 +11,7 @@ import {
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
+import { isValidOidcSessionUser } from "../../lib/oidcStorage";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
@@ -39,12 +40,14 @@ function LoginPage() {
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
+ const hasActiveAuthUser =
+ auth.isAuthenticated && isValidOidcSessionUser(auth.user);
useEffect(() => {
- if (auth.isAuthenticated) {
+ if (hasActiveAuthUser) {
navigate(returnTo, { replace: true });
}
- }, [auth.isAuthenticated, navigate, returnTo]);
+ }, [hasActiveAuthUser, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
diff --git a/devfront/src/features/auth/authPages.test.tsx b/devfront/src/features/auth/authPages.test.tsx
index ddfd9007..a977315e 100644
--- a/devfront/src/features/auth/authPages.test.tsx
+++ b/devfront/src/features/auth/authPages.test.tsx
@@ -2,21 +2,22 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PersistedOidcUser } from "../../lib/oidcStorage";
import AuthCallbackPage from "./AuthCallbackPage";
import AuthGuard from "./AuthGuard";
import AuthPage from "./AuthPage";
import LoginPage from "./LoginPage";
+type AuthTestUser = PersistedOidcUser & {
+ state?: unknown;
+};
+
const authState = {
isAuthenticated: false,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
- user: undefined as
- | {
- state?: unknown;
- }
- | undefined,
+ user: undefined as AuthTestUser | undefined,
signinRedirect: vi.fn(),
};
@@ -33,7 +34,19 @@ vi.mock("../../lib/auth", () => ({
const roots: Root[] = [];
+function createAuthUser(overrides: Partial = {}): AuthTestUser {
+ return {
+ access_token: "token-1",
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
+ profile: { sub: "dev-admin-1" },
+ ...overrides,
+ };
+}
+
beforeEach(() => {
+ window.localStorage.clear();
+ delete (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
+ ._IS_TEST_MODE;
authState.isAuthenticated = false;
authState.isLoading = false;
authState.activeNavigator = undefined;
@@ -137,13 +150,50 @@ describe("devfront auth pages", () => {
expect(redirected.textContent).toContain("Login route");
authState.isAuthenticated = true;
+ authState.user = createAuthUser();
const protectedPage = await renderWithRouter();
expect(protectedPage.textContent).toContain("Protected outlet");
});
+ it("does not restore placeholder unknown OIDC entries as authenticated sessions", async () => {
+ window.localStorage.setItem(
+ "oidc.user:issuer:devfront",
+ JSON.stringify({
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
+ profile: {
+ name: "Unknown User",
+ email: "unknown@example.com",
+ },
+ }),
+ );
+
+ const redirected = await renderWithRouter();
+
+ expect(redirected.textContent).toContain("Login route");
+ });
+
+ it("keeps the login page visible for placeholder authenticated users", async () => {
+ authState.isAuthenticated = true;
+ authState.user = {
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
+ profile: {
+ name: "Unknown User",
+ email: "unknown@example.com",
+ },
+ };
+
+ const container = await renderWithRouter(, {
+ entry: "/login?returnTo=/profile",
+ path: "/login",
+ });
+
+ expect(container.textContent).toContain("개발자 포털 로그인");
+ expect(container.textContent).not.toContain("Profile route");
+ });
+
it("navigates from callback by auth result and stored return target", async () => {
authState.isAuthenticated = true;
- authState.user = { state: { returnTo: "/profile" } };
+ authState.user = createAuthUser({ state: { returnTo: "/profile" } });
const authenticated = await renderWithRouter(, {
entry: "/auth/callback",
diff --git a/devfront/src/lib/oidcStorage.test.ts b/devfront/src/lib/oidcStorage.test.ts
index c5c35f59..3015c919 100644
--- a/devfront/src/lib/oidcStorage.test.ts
+++ b/devfront/src/lib/oidcStorage.test.ts
@@ -48,14 +48,14 @@ describe("findPersistedOidcUser", () => {
JSON.stringify({
access_token: "token-1",
expires_at: expiresAt,
- profile: { name: "Dev Admin" },
+ profile: { sub: "dev-admin-1", name: "Dev Admin" },
}),
);
expect(findPersistedOidcUser(storage)).toEqual({
access_token: "token-1",
expires_at: expiresAt,
- profile: { name: "Dev Admin" },
+ profile: { sub: "dev-admin-1", name: "Dev Admin" },
});
});
@@ -73,4 +73,20 @@ describe("findPersistedOidcUser", () => {
expect(findPersistedOidcUser(storage)).toBeNull();
});
+
+ it("skips placeholder unknown entries without an access token and subject", () => {
+ const storage = new MemoryStorage();
+ storage.setItem(
+ "oidc.user:issuer:devfront",
+ JSON.stringify({
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
+ profile: {
+ name: "Unknown User",
+ email: "unknown@example.com",
+ },
+ }),
+ );
+
+ expect(findPersistedOidcUser(storage)).toBeNull();
+ });
});
diff --git a/devfront/src/lib/oidcStorage.ts b/devfront/src/lib/oidcStorage.ts
index f3f06176..6f0c09c8 100644
--- a/devfront/src/lib/oidcStorage.ts
+++ b/devfront/src/lib/oidcStorage.ts
@@ -7,6 +7,27 @@ export type PersistedOidcUser = {
const OIDC_USER_KEY_PREFIX = "oidc.user:";
const OIDC_CLIENT_ID = "devfront";
+export function isValidOidcSessionUser(
+ value: PersistedOidcUser | null | undefined,
+): value is PersistedOidcUser & {
+ access_token: string;
+ expires_at: number;
+ profile: Record & { sub: string };
+} {
+ return (
+ value !== null &&
+ value !== undefined &&
+ typeof value.access_token === "string" &&
+ value.access_token.trim() !== "" &&
+ typeof value.expires_at === "number" &&
+ value.expires_at * 1000 > Date.now() &&
+ typeof value.profile === "object" &&
+ value.profile !== null &&
+ typeof value.profile.sub === "string" &&
+ value.profile.sub.trim() !== ""
+ );
+}
+
export function findPersistedOidcUser(
storage: Storage = window.localStorage,
): PersistedOidcUser | null {
@@ -27,10 +48,7 @@ export function findPersistedOidcUser(
try {
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
- if (
- typeof parsed.expires_at === "number" &&
- parsed.expires_at * 1000 > Date.now()
- ) {
+ if (isValidOidcSessionUser(parsed)) {
return parsed;
}
} catch {
diff --git a/devfront/tests/devfront-login-guard.spec.ts b/devfront/tests/devfront-login-guard.spec.ts
new file mode 100644
index 00000000..c12efefc
--- /dev/null
+++ b/devfront/tests/devfront-login-guard.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("DevFront login guard", () => {
+ test("shows the login screen instead of restoring an unknown placeholder session", async ({
+ page,
+ }, testInfo) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem(
+ "oidc.user:http://localhost:5000/oidc:devfront",
+ JSON.stringify({
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
+ profile: {
+ name: "Unknown User",
+ email: "unknown@example.com",
+ },
+ }),
+ );
+ });
+
+ await page.goto("/clients");
+
+ await expect(
+ page.getByRole("heading", { name: "Baron SSO" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "SSO 계정으로 로그인" }),
+ ).toBeVisible();
+ await expect(page.getByText("unknown@example.com")).toHaveCount(0);
+
+ await page.screenshot({
+ path: testInfo.outputPath("login-screen.png"),
+ fullPage: true,
+ });
+ });
+});