1
0
forked from baron/baron-sso

devfront 개발모드 unkown로그인 제거

This commit is contained in:
2026-06-19 08:15:07 +09:00
parent 7ea385a9f4
commit 016d783482
11 changed files with 162 additions and 34 deletions

View File

@@ -1344,6 +1344,11 @@ jobs:
with: with:
node-version: "24" node-version: "24"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.5.2
- name: Get Playwright version - name: Get Playwright version
id: playwright-version id: playwright-version
working-directory: devfront working-directory: devfront

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

@@ -43,17 +43,13 @@ export const devFrontRoutes: RouteObject[] = [
}, },
{ {
path: "/", path: "/",
element: element: <AuthGuard />,
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />, children: [
children: {
import.meta.env.MODE === "development" element: <AppLayout />,
? devFrontAppChildren children: devFrontAppChildren,
: [ },
{ ],
element: <AppLayout />,
children: devFrontAppChildren,
},
],
}, },
]; ];

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { userManager } from "../../lib/auth"; import { userManager } from "../../lib/auth";
import { isValidOidcSessionUser } from "../../lib/oidcStorage";
export default function AuthCallbackPage() { export default function AuthCallbackPage() {
const auth = useAuth(); const auth = useAuth();
@@ -16,7 +17,7 @@ export default function AuthCallbackPage() {
return; return;
} }
if (auth.isAuthenticated) { if (auth.isAuthenticated && isValidOidcSessionUser(auth.user)) {
const returnTo = const returnTo =
typeof auth.user?.state === "object" && typeof auth.user?.state === "object" &&
auth.user?.state !== null && auth.user?.state !== null &&
@@ -29,7 +30,7 @@ export default function AuthCallbackPage() {
console.error("Auth Error:", auth.error); console.error("Auth Error:", auth.error);
navigate("/login", { replace: true }); navigate("/login", { replace: true });
} }
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]); }, [auth.isAuthenticated, auth.error, navigate, auth.user]);
return <div>Loading Auth...</div>; return <div>Loading Auth...</div>;
} }

View File

@@ -2,22 +2,26 @@ import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { userManager } from "../../lib/auth"; import { userManager } from "../../lib/auth";
import { findPersistedOidcUser } from "../../lib/oidcStorage"; import {
findPersistedOidcUser,
isValidOidcSessionUser,
} from "../../lib/oidcStorage";
export default function AuthGuard() { export default function AuthGuard() {
const auth = useAuth(); const auth = useAuth();
const hasActiveAuthUser =
auth.isAuthenticated && isValidOidcSessionUser(auth.user);
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() => const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
findPersistedOidcUser() ? true : null, findPersistedOidcUser() ? true : null,
); );
const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode = const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true || navigator.webdriver === true; ._IS_TEST_MODE === true;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (isDevelopmentMode || isTestMode) { if (isTestMode) {
setHasStoredUser(true); setHasStoredUser(true);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -36,7 +40,7 @@ export default function AuthGuard() {
.getUser() .getUser()
.then((user) => { .then((user) => {
if (!cancelled) { if (!cancelled) {
setHasStoredUser(Boolean(user && !user.expired)); setHasStoredUser(isValidOidcSessionUser(user));
} }
}) })
.catch(() => { .catch(() => {
@@ -50,7 +54,7 @@ export default function AuthGuard() {
}; };
}, [isTestMode]); }, [isTestMode]);
if (isDevelopmentMode || isTestMode) { if (isTestMode) {
return <Outlet />; return <Outlet />;
} }
@@ -76,7 +80,7 @@ export default function AuthGuard() {
); );
} }
if (!auth.isAuthenticated && !hasStoredUser) { if (!hasActiveAuthUser && !hasStoredUser) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }

View File

@@ -11,6 +11,7 @@ import {
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig"; import { canStartBrowserPkceLogin } from "../../lib/authConfig";
import { isValidOidcSessionUser } from "../../lib/oidcStorage";
const insecurePkceMessage = const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요."; "이 주소에서는 브라우저 보안 정책 때문에 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; return message;
}, [auth.error?.message]); }, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage; const visibleLoginError = loginError || authErrorMessage;
const hasActiveAuthUser =
auth.isAuthenticated && isValidOidcSessionUser(auth.user);
useEffect(() => { useEffect(() => {
if (auth.isAuthenticated) { if (hasActiveAuthUser) {
navigate(returnTo, { replace: true }); navigate(returnTo, { replace: true });
} }
}, [auth.isAuthenticated, navigate, returnTo]); }, [hasActiveAuthUser, navigate, returnTo]);
useEffect(() => { useEffect(() => {
if (!shouldAutoLogin) { if (!shouldAutoLogin) {

View File

@@ -2,21 +2,22 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PersistedOidcUser } from "../../lib/oidcStorage";
import AuthCallbackPage from "./AuthCallbackPage"; import AuthCallbackPage from "./AuthCallbackPage";
import AuthGuard from "./AuthGuard"; import AuthGuard from "./AuthGuard";
import AuthPage from "./AuthPage"; import AuthPage from "./AuthPage";
import LoginPage from "./LoginPage"; import LoginPage from "./LoginPage";
type AuthTestUser = PersistedOidcUser & {
state?: unknown;
};
const authState = { const authState = {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
activeNavigator: undefined as string | undefined, activeNavigator: undefined as string | undefined,
error: null as Error | null, error: null as Error | null,
user: undefined as user: undefined as AuthTestUser | undefined,
| {
state?: unknown;
}
| undefined,
signinRedirect: vi.fn(), signinRedirect: vi.fn(),
}; };
@@ -33,7 +34,19 @@ vi.mock("../../lib/auth", () => ({
const roots: Root[] = []; const roots: Root[] = [];
function createAuthUser(overrides: Partial<AuthTestUser> = {}): AuthTestUser {
return {
access_token: "token-1",
expires_at: Math.floor(Date.now() / 1000) + 3600,
profile: { sub: "dev-admin-1" },
...overrides,
};
}
beforeEach(() => { beforeEach(() => {
window.localStorage.clear();
delete (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE;
authState.isAuthenticated = false; authState.isAuthenticated = false;
authState.isLoading = false; authState.isLoading = false;
authState.activeNavigator = undefined; authState.activeNavigator = undefined;
@@ -137,13 +150,50 @@ describe("devfront auth pages", () => {
expect(redirected.textContent).toContain("Login route"); expect(redirected.textContent).toContain("Login route");
authState.isAuthenticated = true; authState.isAuthenticated = true;
authState.user = createAuthUser();
const protectedPage = await renderWithRouter(<AuthGuard />); const protectedPage = await renderWithRouter(<AuthGuard />);
expect(protectedPage.textContent).toContain("Protected outlet"); 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(<AuthGuard />);
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(<LoginPage />, {
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 () => { it("navigates from callback by auth result and stored return target", async () => {
authState.isAuthenticated = true; authState.isAuthenticated = true;
authState.user = { state: { returnTo: "/profile" } }; authState.user = createAuthUser({ state: { returnTo: "/profile" } });
const authenticated = await renderWithRouter(<AuthCallbackPage />, { const authenticated = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback", entry: "/auth/callback",

View File

@@ -48,14 +48,14 @@ describe("findPersistedOidcUser", () => {
JSON.stringify({ JSON.stringify({
access_token: "token-1", access_token: "token-1",
expires_at: expiresAt, expires_at: expiresAt,
profile: { name: "Dev Admin" }, profile: { sub: "dev-admin-1", name: "Dev Admin" },
}), }),
); );
expect(findPersistedOidcUser(storage)).toEqual({ expect(findPersistedOidcUser(storage)).toEqual({
access_token: "token-1", access_token: "token-1",
expires_at: expiresAt, 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(); 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();
});
}); });

View File

@@ -7,6 +7,27 @@ export type PersistedOidcUser = {
const OIDC_USER_KEY_PREFIX = "oidc.user:"; const OIDC_USER_KEY_PREFIX = "oidc.user:";
const OIDC_CLIENT_ID = "devfront"; const OIDC_CLIENT_ID = "devfront";
export function isValidOidcSessionUser(
value: PersistedOidcUser | null | undefined,
): value is PersistedOidcUser & {
access_token: string;
expires_at: number;
profile: Record<string, unknown> & { 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( export function findPersistedOidcUser(
storage: Storage = window.localStorage, storage: Storage = window.localStorage,
): PersistedOidcUser | null { ): PersistedOidcUser | null {
@@ -27,10 +48,7 @@ export function findPersistedOidcUser(
try { try {
const parsed = JSON.parse(rawValue) as PersistedOidcUser; const parsed = JSON.parse(rawValue) as PersistedOidcUser;
if ( if (isValidOidcSessionUser(parsed)) {
typeof parsed.expires_at === "number" &&
parsed.expires_at * 1000 > Date.now()
) {
return parsed; return parsed;
} }
} catch { } catch {

View File

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