1
0
forked from baron/baron-sso

callback 검증 보강. seed-tenant 추가보강

This commit is contained in:
2026-05-11 11:03:11 +09:00
parent f46a7cc088
commit 9a64a16cb9
28 changed files with 2832 additions and 133 deletions

View File

@@ -3,6 +3,19 @@ set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')" app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_URL:-}" ]; then
export VITE_ADMIN_PUBLIC_URL="$ADMINFRONT_URL"
fi
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_CALLBACK_URLS:-}" ]; then
first_admin_callback="${ADMINFRONT_CALLBACK_URLS%%,*}"
case "$first_admin_callback" in
http://*/auth/callback | https://*/auth/callback)
export VITE_ADMIN_PUBLIC_URL="${first_admin_callback%/auth/callback}"
;;
esac
fi
case "$app_env" in case "$app_env" in
production|prod|stage|staging) production|prod|stage|staging)
mode="production" mode="production"
@@ -12,6 +25,11 @@ case "$app_env" in
;; ;;
esac esac
if [ "${1:-}" = "--print-admin-public-url" ]; then
printf '%s\n' "${VITE_ADMIN_PUBLIC_URL:-}"
exit 0
fi
if [ "${1:-}" = "--print-mode" ]; then if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode" printf '%s\n' "$mode"
exit 0 exit 0

View File

@@ -0,0 +1,18 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { buildAdminAuthRedirectUris } from "../lib/authConfig";
import { adminRoutes } from "./routes";
describe("admin routes", () => {
it("accepts the auth callback path generated from the public admin URL", () => {
const { redirectUri } = buildAdminAuthRedirectUris(
"https://sadmin.hmac.kr",
);
const callbackPath = new URL(redirectUri).pathname;
const matches = matchRoutes(adminRoutes, callbackPath);
expect(callbackPath).toBe("/auth/callback");
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
});
});

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout"; import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
@@ -19,48 +20,51 @@ import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDet
import UserCreatePage from "../features/users/UserCreatePage"; import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage"; import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage"; import UserListPage from "../features/users/UserListPage";
import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
export const adminRoutes: RouteObject[] = [
{
path: "/login",
element: <LoginPage />,
},
{
path: ADMIN_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
];
export const router = createBrowserRouter( export const router = createBrowserRouter(
[ adminRoutes,
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],
// React Router v7 플래그는 Provider에서 적용합니다. // React Router v7 플래그는 Provider에서 적용합니다.
); );

View File

@@ -1,14 +1,24 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context"; import type { AuthProviderProps } from "react-oidc-context";
import {
buildAdminAuthRedirectUris,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
window.location.origin,
);
const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin);
export const oidcConfig: AuthProviderProps = { export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: adminRedirectUris.redirectUri,
response_type: "code", response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: adminRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: `${window.location.origin}/auth/callback`, popup_redirect_uri: adminRedirectUris.popupRedirectUri,
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false, automaticSilentRenew: false,
}; };

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
buildAdminAuthRedirectUris,
resolveAdminPublicOrigin,
} from "./authConfig";
describe("admin auth config", () => {
it("uses the explicit public admin origin for staging callback URLs", () => {
const publicOrigin = resolveAdminPublicOrigin(
"https://sadmin.hmac.kr",
"http://127.0.0.1:5173",
);
expect(publicOrigin).toBe("https://sadmin.hmac.kr");
expect(buildAdminAuthRedirectUris(publicOrigin)).toEqual({
redirectUri: "https://sadmin.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sadmin.hmac.kr",
popupRedirectUri: "https://sadmin.hmac.kr/auth/callback",
});
});
it("falls back to the browser origin when no explicit public origin is set", () => {
expect(resolveAdminPublicOrigin("", "http://localhost:5173")).toBe(
"http://localhost:5173",
);
});
});

View File

@@ -0,0 +1,33 @@
export interface AdminAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveAdminPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildAdminAuthRedirectUris(
publicOrigin: string,
): AdminAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
};
}

View File

@@ -42,15 +42,6 @@ type InitialTenantConfig struct {
func SeedTenants(db *gorm.DB) error { func SeedTenants(db *gorm.DB) error {
slog.Info("[Bootstrap] Checking initial tenant seed...") slog.Info("[Bootstrap] Checking initial tenant seed...")
var tenantCount int64
if err := db.Model(&domain.Tenant{}).Count(&tenantCount).Error; err != nil {
return fmt.Errorf("count tenants before seed: %w", err)
}
if tenantCount > 0 {
slog.Info("[Bootstrap] Tenant seed skipped because tenants already exist", "count", tenantCount)
return nil
}
configs, err := loadSeedTenantConfigs() configs, err := loadSeedTenantConfigs()
if err != nil { if err != nil {
return err return err
@@ -59,7 +50,62 @@ func SeedTenants(db *gorm.DB) error {
return errors.New("seed tenant csv has no tenant rows") return errors.New("seed tenant csv has no tenant rows")
} }
return seedTenantConfigs(db, configs) existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db)
if err != nil {
return err
}
missingConfigs := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs)
if len(missingConfigs) == 0 {
slog.Info("[Bootstrap] Tenant seed skipped because all seed slugs already exist", "count", len(configs))
return nil
}
slog.Info(
"[Bootstrap] Tenant seed will create missing seed tenants",
"total", len(configs),
"missing", len(missingConfigs),
"existing", len(configs)-len(missingConfigs),
)
return seedTenantConfigs(db, missingConfigs)
}
func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) {
var tenants []domain.Tenant
if err := db.Select("id", "slug").Find(&tenants).Error; err != nil {
return nil, nil, fmt.Errorf("load existing tenants before seed: %w", err)
}
slugs := make(map[string]bool, len(tenants))
ids := make(map[string]bool, len(tenants))
for _, tenant := range tenants {
slug := strings.TrimSpace(strings.ToLower(tenant.Slug))
if slug != "" {
slugs[slug] = true
}
id := strings.TrimSpace(strings.ToLower(tenant.ID))
if id != "" {
ids[id] = true
}
}
return slugs, ids, nil
}
func filterMissingSeedTenantConfigs(configs []InitialTenantConfig, existingSlugs map[string]bool, existingIDs map[string]bool) []InitialTenantConfig {
filtered := make([]InitialTenantConfig, 0, len(configs))
for _, config := range configs {
slug := strings.TrimSpace(strings.ToLower(config.Slug))
id := strings.TrimSpace(strings.ToLower(config.TenantID))
if slug == "" || existingSlugs[slug] || (id != "" && existingIDs[id]) {
continue
}
filtered = append(filtered, config)
existingSlugs[slug] = true
if id != "" {
existingIDs[id] = true
}
}
return filtered
} }
func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error { func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {

View File

@@ -2,9 +2,19 @@ package bootstrap
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"context"
"log"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
gorm_postgres "gorm.io/driver/postgres"
"gorm.io/gorm"
) )
func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) { func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
@@ -165,3 +175,144 @@ func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
t.Fatal("normal-tenant must not be detected as seed tenant") t.Fatal("normal-tenant must not be detected as seed tenant")
} }
} }
func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
configs := []InitialTenantConfig{
{TenantID: "existing-root-id", Name: "Existing Root", Slug: "existing-root"},
{Name: "Missing Child", Slug: "missing-child", ParentSlug: "existing-root"},
{TenantID: "existing-child-id", Name: "Existing Child", Slug: "existing-child", ParentSlug: "existing-root"},
{TenantID: "existing-other-id", Name: "Conflicting ID", Slug: "new-slug"},
}
existingSlugs := map[string]bool{
"existing-root": true,
"existing-child": true,
}
existingIDs := map[string]bool{
"existing-root-id": true,
"existing-child-id": true,
"existing-other-id": true,
}
filtered := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs)
if len(filtered) != 1 {
t.Fatalf("filtered count = %d, want 1: %#v", len(filtered), filtered)
}
if filtered[0].Slug != "missing-child" {
t.Fatalf("filtered slug = %q, want missing-child", filtered[0].Slug)
}
}
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
ctx := context.Background()
postgresContainer, err := postgres_module.Run(ctx,
"postgres:16-alpine",
postgres_module.WithDatabase("testdb"),
postgres_module.WithUsername("user"),
postgres_module.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := postgresContainer.Terminate(ctx); err != nil {
log.Printf("failed to terminate postgres container: %v", err)
}
})
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get postgres connection string: %v", err)
}
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open postgres connection: %v", err)
}
if err := db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate seed test tables: %v", err)
}
existingRoot := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Name: "Existing Root Name",
Slug: "existing-root",
Type: domain.TenantTypeCompanyGroup,
Description: "manual tenant must not be overwritten",
Status: domain.TenantStatusActive,
}
nonSeedTenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000002",
Name: "Manual Tenant",
Slug: "manual-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&existingRoot).Error; err != nil {
t.Fatalf("failed to create existing root tenant: %v", err)
}
if err := db.Create(&nonSeedTenant).Error; err != nil {
t.Fatalf("failed to create non-seed tenant: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" +
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv(seedTenantCSVPathEnv, path)
if err := SeedTenants(db); err != nil {
t.Fatalf("SeedTenants returned error: %v", err)
}
var root domain.Tenant
if err := db.First(&root, "slug = ?", "existing-root").Error; err != nil {
t.Fatalf("failed to load existing root after seed: %v", err)
}
if root.ID != existingRoot.ID {
t.Fatalf("existing root ID = %q, want %q", root.ID, existingRoot.ID)
}
if root.Name != existingRoot.Name {
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
}
var child domain.Tenant
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
t.Fatalf("missing seed child was not created: %v", err)
}
if child.ParentID == nil || *child.ParentID != existingRoot.ID {
t.Fatalf("child parent ID = %v, want %q", child.ParentID, existingRoot.ID)
}
if len(child.Domains) != 1 || child.Domains[0].Domain != "child.example.com" {
t.Fatalf("child domains = %#v, want child.example.com", child.Domains)
}
var rootCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "existing-root").Count(&rootCount).Error; err != nil {
t.Fatalf("failed to count existing root rows: %v", err)
}
if rootCount != 1 {
t.Fatalf("existing-root row count = %d, want 1", rootCount)
}
var conflictingIDCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
t.Fatalf("failed to count conflicting-id rows: %v", err)
}
if conflictingIDCount != 0 {
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"lint": "biome check .", "lint": "biome check .",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test", "test": "playwright test",
"test:unit": "vitest run",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts", "test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
"test:ui": "playwright test --ui" "test:ui": "playwright test --ui"
}, },
@@ -43,10 +44,12 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.3" "vite": "^8.0.3",
"vitest": "^4.1.5"
} }
} }

View File

@@ -3,6 +3,19 @@ set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')" app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
if [ -z "${VITE_DEVFRONT_PUBLIC_URL:-}" ] && [ -n "${DEVFRONT_URL:-}" ]; then
export VITE_DEVFRONT_PUBLIC_URL="$DEVFRONT_URL"
fi
if [ -z "${VITE_DEVFRONT_PUBLIC_URL:-}" ] && [ -n "${DEVFRONT_CALLBACK_URLS:-}" ]; then
first_devfront_callback="${DEVFRONT_CALLBACK_URLS%%,*}"
case "$first_devfront_callback" in
http://*/auth/callback | https://*/auth/callback)
export VITE_DEVFRONT_PUBLIC_URL="${first_devfront_callback%/auth/callback}"
;;
esac
fi
case "$app_env" in case "$app_env" in
production|prod|stage|staging) production|prod|stage|staging)
mode="production" mode="production"
@@ -12,6 +25,11 @@ case "$app_env" in
;; ;;
esac esac
if [ "${1:-}" = "--print-public-url" ]; then
printf '%s\n' "${VITE_DEVFRONT_PUBLIC_URL:-}"
exit 0
fi
if [ "${1:-}" = "--print-mode" ]; then if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode" printf '%s\n' "$mode"
exit 0 exit 0

View File

@@ -0,0 +1,13 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
import { devFrontRoutes } from "./routes";
describe("devfront routes", () => {
it("accepts the auth callback path used by the OIDC redirect URI", () => {
const matches = matchRoutes(devFrontRoutes, DEVFRONT_AUTH_CALLBACK_PATH);
expect(matches).not.toBeNull();
expect(matches?.at(-1)?.route.path).toBe(DEVFRONT_AUTH_CALLBACK_PATH);
});
});

View File

@@ -1,4 +1,8 @@
import { Navigate, createBrowserRouter } from "react-router-dom"; import {
Navigate,
type RouteObject,
createBrowserRouter,
} from "react-router-dom";
import AppLayout from "../components/layout/AppLayout"; import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage";
@@ -11,42 +15,45 @@ import ClientRelationsPage from "../features/clients/ClientRelationsPage";
import ClientsPage from "../features/clients/ClientsPage"; import ClientsPage from "../features/clients/ClientsPage";
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage"; import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
import ProfilePage from "../features/profile/ProfilePage"; import ProfilePage from "../features/profile/ProfilePage";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
export const devFrontRoutes: RouteObject[] = [
{
path: "/login",
element: <LoginPage />,
},
{
path: DEVFRONT_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/clients" replace /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
],
},
],
},
];
export const router = createBrowserRouter( export const router = createBrowserRouter(
[ devFrontRoutes,
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/clients" replace /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
],
},
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅) // React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{ {
future: { future: {

View File

@@ -1,14 +1,25 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context"; import type { AuthProviderProps } from "react-oidc-context";
import {
buildDevFrontAuthRedirectUris,
resolveDevFrontPublicOrigin,
} from "./authConfig";
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
window.location.origin,
);
const devFrontRedirectUris =
buildDevFrontAuthRedirectUris(devFrontPublicOrigin);
export const oidcConfig: AuthProviderProps = { export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: devFrontRedirectUris.redirectUri,
response_type: "code", response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: devFrontRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: `${window.location.origin}/auth/callback`, popup_redirect_uri: devFrontRedirectUris.popupRedirectUri,
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false, automaticSilentRenew: false,
}; };

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
DEVFRONT_AUTH_CALLBACK_PATH,
buildDevFrontAuthRedirectUris,
resolveDevFrontPublicOrigin,
} from "./authConfig";
describe("devfront auth config", () => {
it("builds callback URLs from the public origin", () => {
expect(buildDevFrontAuthRedirectUris("https://sdev.hmac.kr")).toEqual({
redirectUri: "https://sdev.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sdev.hmac.kr",
popupRedirectUri: "https://sdev.hmac.kr/auth/callback",
});
});
it("uses the browser origin when the configured origin is empty or invalid", () => {
expect(resolveDevFrontPublicOrigin("", "http://localhost:5173")).toBe(
"http://localhost:5173",
);
expect(
resolveDevFrontPublicOrigin("not a url", "http://localhost:5173"),
).toBe("http://localhost:5173");
});
it("keeps the callback path aligned with the registered redirect path", () => {
expect(DEVFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
});
});

View File

@@ -0,0 +1,33 @@
export interface DevFrontAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const DEVFRONT_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveDevFrontPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildDevFrontAuthRedirectUris(
publicOrigin: string,
): DevFrontAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
};
}

11
devfront/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"lint": "biome check .", "lint": "biome check .",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test", "test": "playwright test",
"test:unit": "vitest run",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts", "test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
"test:ui": "playwright test --ui" "test:ui": "playwright test --ui"
}, },
@@ -43,10 +44,12 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.3" "vite": "^8.0.3",
"vitest": "^4.1.5"
} }
} }

View File

@@ -3,6 +3,19 @@ set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')" app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
if [ -z "${VITE_ORGFRONT_PUBLIC_URL:-}" ] && [ -n "${ORGFRONT_URL:-}" ]; then
export VITE_ORGFRONT_PUBLIC_URL="$ORGFRONT_URL"
fi
if [ -z "${VITE_ORGFRONT_PUBLIC_URL:-}" ] && [ -n "${ORGFRONT_CALLBACK_URLS:-}" ]; then
first_orgfront_callback="${ORGFRONT_CALLBACK_URLS%%,*}"
case "$first_orgfront_callback" in
http://*/auth/callback | https://*/auth/callback)
export VITE_ORGFRONT_PUBLIC_URL="${first_orgfront_callback%/auth/callback}"
;;
esac
fi
case "$app_env" in case "$app_env" in
production|prod|stage|staging) production|prod|stage|staging)
mode="production" mode="production"
@@ -12,6 +25,11 @@ case "$app_env" in
;; ;;
esac esac
if [ "${1:-}" = "--print-public-url" ]; then
printf '%s\n' "${VITE_ORGFRONT_PUBLIC_URL:-}"
exit 0
fi
if [ "${1:-}" = "--print-mode" ]; then if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode" printf '%s\n' "$mode"
exit 0 exit 0

View File

@@ -0,0 +1,13 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { ORGFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
import { orgFrontRoutes } from "./routes";
describe("orgfront routes", () => {
it("accepts the auth callback path used by the OIDC redirect URI", () => {
const matches = matchRoutes(orgFrontRoutes, ORGFRONT_AUTH_CALLBACK_PATH);
expect(matches).not.toBeNull();
expect(matches?.at(-1)?.route.path).toBe(ORGFRONT_AUTH_CALLBACK_PATH);
});
});

View File

@@ -1,4 +1,8 @@
import { Navigate, createBrowserRouter } from "react-router-dom"; import {
Navigate,
type RouteObject,
createBrowserRouter,
} from "react-router-dom";
import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard"; import AuthGuard from "../features/auth/AuthGuard";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
@@ -9,38 +13,38 @@ import {
OrgPickerEmbedPage, OrgPickerEmbedPage,
OrgPickerPage, OrgPickerPage,
} from "../features/orgchart/routes/OrgPickerPage"; } from "../features/orgchart/routes/OrgPickerPage";
import { ORGFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
export const router = createBrowserRouter( export const orgFrontRoutes: RouteObject[] = [
[
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{ index: true, element: <Navigate to="/chart" replace /> },
{
element: <OrgFrontLayout />,
children: [
{ path: "chart", element: <TenantOrgChartPage /> },
{ path: "chart/:tenantId", element: <TenantOrgChartPage /> },
{ path: "picker", element: <OrgPickerPage /> },
{ path: "embed-preview", element: <OrgPickerEmbedPreviewPage /> },
],
},
{ path: "embed/picker", element: <OrgPickerEmbedPage /> },
],
},
],
{ {
future: { path: "/login",
v7_startTransition: true, element: <LoginPage />,
}, },
} as unknown as Parameters<typeof createBrowserRouter>[1], {
); path: ORGFRONT_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{ index: true, element: <Navigate to="/chart" replace /> },
{
element: <OrgFrontLayout />,
children: [
{ path: "chart", element: <TenantOrgChartPage /> },
{ path: "chart/:tenantId", element: <TenantOrgChartPage /> },
{ path: "picker", element: <OrgPickerPage /> },
{ path: "embed-preview", element: <OrgPickerEmbedPreviewPage /> },
],
},
{ path: "embed/picker", element: <OrgPickerEmbedPage /> },
],
},
];
export const router = createBrowserRouter(orgFrontRoutes, {
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1]);

View File

@@ -1,15 +1,26 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context"; import type { AuthProviderProps } from "react-oidc-context";
import {
buildOrgFrontAuthRedirectUris,
resolveOrgFrontPublicOrigin,
} from "./authConfig";
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
window.location.origin,
);
const orgFrontRedirectUris =
buildOrgFrontAuthRedirectUris(orgFrontPublicOrigin);
export const oidcConfig: AuthProviderProps = { export const oidcConfig: AuthProviderProps = {
authority: authority:
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront", client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: orgFrontRedirectUris.redirectUri,
response_type: "code", response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: orgFrontRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: `${window.location.origin}/auth/callback`, popup_redirect_uri: orgFrontRedirectUris.popupRedirectUri,
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false, automaticSilentRenew: false,
}; };

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
ORGFRONT_AUTH_CALLBACK_PATH,
buildOrgFrontAuthRedirectUris,
resolveOrgFrontPublicOrigin,
} from "./authConfig";
describe("orgfront auth config", () => {
it("builds callback URLs from the public origin", () => {
expect(buildOrgFrontAuthRedirectUris("https://sorg.hmac.kr")).toEqual({
redirectUri: "https://sorg.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sorg.hmac.kr",
popupRedirectUri: "https://sorg.hmac.kr/auth/callback",
});
});
it("uses the browser origin when the configured origin is empty or invalid", () => {
expect(resolveOrgFrontPublicOrigin("", "http://localhost:5174")).toBe(
"http://localhost:5174",
);
expect(
resolveOrgFrontPublicOrigin("not a url", "http://localhost:5174"),
).toBe("http://localhost:5174");
});
it("keeps the callback path aligned with the registered redirect path", () => {
expect(ORGFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
});
});

View File

@@ -0,0 +1,33 @@
export interface OrgFrontAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const ORGFRONT_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveOrgFrontPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildOrgFrontAuthRedirectUris(
publicOrigin: string,
): OrgFrontAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
};
}

11
orgfront/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME_SCRIPT="$ROOT_DIR/adminfront/scripts/runtime-mode.sh"
from_admin_url="$(
APP_ENV=stage \
ADMINFRONT_URL=https://sadmin.hmac.kr \
sh "$RUNTIME_SCRIPT" --print-admin-public-url
)"
if [[ "$from_admin_url" != "https://sadmin.hmac.kr" ]]; then
echo "ERROR: ADMINFRONT_URL was not exported as VITE_ADMIN_PUBLIC_URL" >&2
exit 1
fi
from_callback="$(
APP_ENV=stage \
ADMINFRONT_CALLBACK_URLS=https://sadmin.hmac.kr/auth/callback \
sh "$RUNTIME_SCRIPT" --print-admin-public-url
)"
if [[ "$from_callback" != "https://sadmin.hmac.kr" ]]; then
echo "ERROR: ADMINFRONT_CALLBACK_URLS did not derive VITE_ADMIN_PUBLIC_URL" >&2
exit 1
fi
explicit_value="$(
APP_ENV=stage \
ADMINFRONT_URL=https://wrong.example.test \
VITE_ADMIN_PUBLIC_URL=https://sadmin.hmac.kr \
sh "$RUNTIME_SCRIPT" --print-admin-public-url
)"
if [[ "$explicit_value" != "https://sadmin.hmac.kr" ]]; then
echo "ERROR: explicit VITE_ADMIN_PUBLIC_URL should take precedence" >&2
exit 1
fi
echo "OK: AdminFront public URL env policy is stable"

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
assert_contains() {
local file="$1"
local pattern="$2"
if ! grep -Fq -- "$pattern" "$file"; then
echo "ERROR: missing pattern in $file: $pattern" >&2
exit 1
fi
}
assert_public_url() {
local app_dir="$1"
local app_url_env="$2"
local callback_env="$3"
local vite_public_env="$4"
local expected_url="$5"
local runtime_script="$ROOT_DIR/$app_dir/scripts/runtime-mode.sh"
assert_contains "$runtime_script" "--print-public-url"
local from_app_url
from_app_url="$(
env APP_ENV=stage \
"$app_url_env=$expected_url" \
sh "$runtime_script" --print-public-url
)"
if [[ "$from_app_url" != "$expected_url" ]]; then
echo "ERROR: $app_url_env was not exported as $vite_public_env for $app_dir" >&2
exit 1
fi
local from_callback
from_callback="$(
env APP_ENV=stage \
"$callback_env=$expected_url/auth/callback" \
sh "$runtime_script" --print-public-url
)"
if [[ "$from_callback" != "$expected_url" ]]; then
echo "ERROR: $callback_env did not derive $vite_public_env for $app_dir" >&2
exit 1
fi
local explicit_value
explicit_value="$(
env APP_ENV=stage \
"$app_url_env=https://wrong.example.test" \
"$vite_public_env=$expected_url" \
sh "$runtime_script" --print-public-url
)"
if [[ "$explicit_value" != "$expected_url" ]]; then
echo "ERROR: explicit $vite_public_env should take precedence for $app_dir" >&2
exit 1
fi
}
assert_contains "$ROOT_DIR/devfront/src/lib/authConfig.ts" "DEVFRONT_AUTH_CALLBACK_PATH"
assert_contains "$ROOT_DIR/devfront/src/app/routes.tsx" "DEVFRONT_AUTH_CALLBACK_PATH"
assert_contains "$ROOT_DIR/devfront/src/lib/auth.ts" "VITE_DEVFRONT_PUBLIC_URL"
assert_public_url \
"devfront" \
"DEVFRONT_URL" \
"DEVFRONT_CALLBACK_URLS" \
"VITE_DEVFRONT_PUBLIC_URL" \
"https://sdev.hmac.kr"
assert_contains "$ROOT_DIR/orgfront/src/lib/authConfig.ts" "ORGFRONT_AUTH_CALLBACK_PATH"
assert_contains "$ROOT_DIR/orgfront/src/app/routes.tsx" "ORGFRONT_AUTH_CALLBACK_PATH"
assert_contains "$ROOT_DIR/orgfront/src/lib/auth.ts" "VITE_ORGFRONT_PUBLIC_URL"
assert_public_url \
"orgfront" \
"ORGFRONT_URL" \
"ORGFRONT_CALLBACK_URLS" \
"VITE_ORGFRONT_PUBLIC_URL" \
"https://sorg.hmac.kr"
echo "OK: DevFront/OrgFront callback public URL policy is stable"