forked from baron/baron-sso
callback 검증 보강. seed-tenant 추가보강
This commit is contained in:
@@ -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
|
||||||
|
|||||||
18
adminfront/src/app/routes.test.tsx
Normal file
18
adminfront/src/app/routes.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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에서 적용합니다.
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
27
adminfront/src/lib/authConfig.test.ts
Normal file
27
adminfront/src/lib/authConfig.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
adminfront/src/lib/authConfig.ts
Normal file
33
adminfront/src/lib/authConfig.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1014
devfront/package-lock.json
generated
1014
devfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
devfront/src/app/routes.test.tsx
Normal file
13
devfront/src/app/routes.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
29
devfront/src/lib/authConfig.test.ts
Normal file
29
devfront/src/lib/authConfig.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
33
devfront/src/lib/authConfig.ts
Normal file
33
devfront/src/lib/authConfig.ts
Normal 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
11
devfront/vitest.config.ts
Normal 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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
1014
orgfront/package-lock.json
generated
1014
orgfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
orgfront/src/app/routes.test.tsx
Normal file
13
orgfront/src/app/routes.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
29
orgfront/src/lib/authConfig.test.ts
Normal file
29
orgfront/src/lib/authConfig.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
33
orgfront/src/lib/authConfig.ts
Normal file
33
orgfront/src/lib/authConfig.ts
Normal 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
11
orgfront/vitest.config.ts
Normal 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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
41
test/adminfront_public_url_env_policy_test.sh
Normal file
41
test/adminfront_public_url_env_policy_test.sh
Normal 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"
|
||||||
80
test/dev_org_front_callback_public_url_policy_test.sh
Normal file
80
test/dev_org_front_callback_public_url_policy_test.sh
Normal 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"
|
||||||
Reference in New Issue
Block a user