forked from baron/baron-sso
devfront 개발모드 unkown로그인 제거
This commit is contained in:
@@ -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 |
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
35
devfront/tests/devfront-login-guard.spec.ts
Normal file
35
devfront/tests/devfront-login-guard.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user