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, + }); + }); +});