1
0
forked from baron/baron-sso

Merge pull request 'feature/df-content' (#253) from feature/df-content into dev

Reviewed-on: baron/baron-sso#253
This commit is contained in:
2026-02-12 14:32:07 +09:00
24 changed files with 559 additions and 84 deletions

View File

@@ -92,6 +92,8 @@ KETO_READ_URL=http://keto:4466
KETO_WRITE_URL=http://keto:4467
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
KETO_READ_URL=http://keto:4466
KETO_WRITE_URL=http://keto:4467
# Kratos Selfservice UI upstreams (override for deployments)
ORY_SDK_URL=http://kratos:4433

View File

@@ -489,6 +489,7 @@ func main() {
// Auth Proxy Routes
auth := api.Group("/auth")
auth.All("/oidc/*", authHandler.ProxyOidc)
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
@@ -553,7 +554,7 @@ func main() {
KetoService: ketoService,
})
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin},
AuthHandler: authHandler,
KetoService: ketoService,
})

View File

@@ -34,6 +34,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error {
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
"role": domain.RoleSuperAdmin,
},
}

View File

@@ -1526,7 +1526,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
loginID := strings.TrimSpace(req.LoginID)
ale.LoginIDs["loginId"] = req.LoginID // 원문
ale.LoginIDs["loginId_normalized"] = loginID
ale.NewPassword = req.Password // For test only, logging password (sensitive)
// ale.NewPassword = req.Password // For test only, logging password (sensitive)
ale.Log(slog.LevelInfo, "Attempting to login")
@@ -1568,22 +1568,25 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 ---
if req.LoginChallenge != "" {
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge, "subject", subject)
// Check if the client is active
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
if err == nil && loginReq != nil && loginReq.Client.Metadata != nil {
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
if strings.ToLower(status) == "inactive" {
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
if err == nil && loginReq != nil {
slog.Info("OIDC Client Info", "client_id", loginReq.Client.ClientID, "name", loginReq.Client.ClientName)
if loginReq.Client.Metadata != nil {
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
if strings.ToLower(status) == "inactive" {
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
}
}
}
}
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil {
slog.Error("failed to accept hydra login request", "error", err)
slog.Error("failed to accept hydra login request", "error", err, "challenge", req.LoginChallenge)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
}
slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo)
@@ -1595,6 +1598,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
resp := fiber.Map{
"sessionToken": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility
"status": "ok",
"provider": h.IdpProvider.Name(),
}
@@ -2480,7 +2484,56 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
return phone
}
// GetMe - Returns current user's profile with enriched data from local DB
// ProxyOidc - 프론트엔드의 OIDC 요청을 내부 Hydra 서비스로 프록시합니다.
func (h *AuthHandler) ProxyOidc(c *fiber.Ctx) error {
path := c.Params("*")
// [Strict] Always use internal Docker network address for proxying to avoid external loops
targetURL := "http://hydra:4444"
// 프록시 URL 구성
u, err := url.Parse(targetURL)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "invalid hydra public url")
}
u.Path = strings.TrimRight(u.Path, "/") + "/" + path
u.RawQuery = string(c.Request().URI().QueryString())
slog.Debug("Proxying OIDC request", "from", c.Path(), "to", u.String())
// 요청 준비
req, err := http.NewRequestWithContext(c.Context(), c.Method(), u.String(), bytes.NewReader(c.Body()))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to create proxy request")
}
// 헤더 복사
c.Request().Header.VisitAll(func(key, value []byte) {
k := string(key)
if k != "Host" && k != "Connection" {
req.Header.Add(k, string(value))
}
})
// 요청 실행 (Hydra 내부 HttpClient 사용)
resp, err := h.Hydra.HttpClient().Do(req)
if err != nil {
return fiber.NewError(fiber.StatusServiceUnavailable, "hydra public api unavailable")
}
defer resp.Body.Close()
// 응답 헤더 복사
for k, values := range resp.Header {
for _, v := range values {
c.Set(k, v)
}
}
// 상태 코드 및 바디 설정
c.Status(resp.StatusCode)
_, err = io.Copy(c.Response().BodyWriter(), resp.Body)
return err
}
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
profile, err := h.resolveCurrentProfile(c)
if err != nil {
@@ -4797,26 +4850,40 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var result struct {
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
return result.Identity.ID, result.Identity.Traits, nil
}
}
}
var result struct {
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
// 2. Kratos 실패 시 Hydra Introspection 시도 (OIDC Access Token 대응)
if h.Hydra != nil {
slog.Debug("[Auth] Kratos whoami failed, trying Hydra introspection", "token_prefix", sessionToken[:min(len(sessionToken), 10)])
introspection, err := h.Hydra.IntrospectToken(context.Background(), sessionToken)
if err == nil && introspection["active"] == true {
subject, _ := introspection["sub"].(string)
if subject != "" {
// Hydra는 Traits를 직접 주지 않으므로, Kratos Admin API로 상세 정보를 가져옴
identity, err := h.KratosAdmin.GetIdentity(context.Background(), subject)
if err == nil && identity != nil {
return identity.ID, identity.Traits, nil
}
// Identity 정보가 없더라도 최소한 Subject는 반환
return subject, map[string]interface{}{}, nil
}
}
}
return result.Identity.ID, result.Identity.Traits, nil
return "", nil, fmt.Errorf("invalid session or token")
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {

View File

@@ -3,6 +3,7 @@ package middleware
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"fmt"
"log/slog"
"github.com/gofiber/fiber/v2"
@@ -25,7 +26,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
return func(c *fiber.Ctx) error {
profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"})
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_keto)"})
}
// Store profile in locals for further use in handlers
@@ -43,7 +44,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
}
if objectID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "권한 검증을 위한 대상 ID가 누락되었습니다."})
}
// Set tenant_id for audit logging if namespace is Tenant
@@ -55,7 +56,9 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed {
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 요청하신 리소스에 대한 상세 권한(Keto)이 부족합니다. 관리자에게 문의하세요.", profile.Role),
})
}
return c.Next()
@@ -73,7 +76,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "unauthorized (trace:rbac_role): " + err.Error(),
"error": "인증 정보 조회에 실패했습니다: " + err.Error(),
})
}
@@ -102,7 +105,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
"path", c.Path(),
)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: insufficient permissions",
"error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 이 기능을 사용할 수 없습니다. 관리자에게 문의하여 'rp_admin' 이상의 권한을 확보하세요.", profile.Role),
})
}
@@ -123,7 +126,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"})
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_match)"})
}
// Store profile in locals for further use in handlers
@@ -143,12 +146,12 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: you do not have access to this tenant",
"error": fmt.Sprintf("해당 테넌트에 대한 접근 권한이 없습니다. 사용자님의 '%s' 권한은 소속된 테넌트의 리소스만 관리할 수 있습니다.", profile.Role),
})
}
return c.Next()
}
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"})
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "요청하신 리소스에 접근할 수 없습니다."})
}
}

View File

@@ -47,7 +47,7 @@ func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int)
return nil, err
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -75,7 +75,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
return nil, err
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -114,7 +114,7 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta
}
req.Header.Set("Content-Type", "application/json-patch+json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -145,7 +145,7 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.Hydr
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -174,7 +174,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -202,7 +202,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e
return err
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return err
}
@@ -235,7 +235,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
return nil, err
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
@@ -276,7 +276,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
return err
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return err
}
@@ -289,7 +289,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
return nil
}
func (s *HydraAdminService) httpClient() *http.Client {
func (s *HydraAdminService) HttpClient() *http.Client {
if s.HTTPClient != nil {
return s.HTTPClient
}
@@ -367,7 +367,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err)
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err)
}
@@ -407,7 +407,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err)
}
@@ -449,7 +449,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err)
}
@@ -484,7 +484,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin
return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err)
}
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: get login request failed: %w", err)
}
@@ -532,7 +532,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err)
}
@@ -576,7 +576,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err)
}
@@ -597,3 +597,34 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
}
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (map[string]interface{}, error) {
endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/"))
data := url.Values{}
data.Set("token", token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.HttpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: introspect failed status=%d body=%s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -18,9 +18,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
@@ -2332,6 +2334,15 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -2774,6 +2785,18 @@
"node": ">= 6"
}
},
"node_modules/oidc-client-ts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3095,6 +3118,19 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-oidc-context": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz",
"integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"oidc-client-ts": "^3.1.0",
"react": ">=16.14.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -22,9 +22,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"

View File

@@ -1,5 +1,8 @@
import { Navigate, createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import LoginPage from "../features/auth/LoginPage";
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
@@ -7,16 +10,29 @@ import ClientsPage from "../features/clients/ClientsPage";
export const router = createBrowserRouter(
[
{
path: "/login",
element: <LoginPage />,
},
{
path: "/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
element: <AuthGuard />,
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 /> },
{
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 /> },
],
},
],
},
],

View File

@@ -1,5 +1,6 @@
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n";
import { Toaster } from "../ui/toaster";
@@ -14,10 +15,22 @@ const navItems = [
];
function AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const auth = useAuth();
// OIDC ID Token에서 프로필 정보 추출
// auth.user?.profile에는 OIDC 표준 클레임(sub, email, name 등)이 포함됨
const profile = auth.user?.profile ? {
id: auth.user.profile.sub,
email: auth.user.profile.email,
name: (auth.user.profile.name as string) || (auth.user.profile.preferred_username as string) || auth.user.profile.email,
tenantId: auth.user.profile.tenant_id as string,
tenant: auth.user.profile.tenant as any,
} : null;
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
const root = document.documentElement;
@@ -30,9 +43,20 @@ function AppLayout() {
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const handleLogout = () => {
auth.signoutRedirect();
};
// Set initial tenant ID if profile is loaded and no tenant is selected
useEffect(() => {
if (profile?.tenantId && !window.localStorage.getItem("dev_tenant_id")) {
window.localStorage.setItem("dev_tenant_id", profile.tenantId);
}
}, [profile]);
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
@@ -61,6 +85,11 @@ function AppLayout() {
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
{profile?.tenant && (
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary border border-primary/20">
Tenant: {profile.tenant.name}
</span>
)}
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
@@ -116,6 +145,16 @@ function AppLayout() {
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
{auth.isAuthenticated && (
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-2 rounded-full border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive transition hover:bg-destructive/10"
>
<LogOut size={16} />
Logout
</button>
)}
</div>
</div>
</header>

View File

@@ -1,4 +1,5 @@
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { CheckCircle2, AlertCircle, Info } from "lucide-react";
import { cn } from "../../lib/utils";
import { useToastState } from "./use-toast";

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
export default function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (auth.isAuthenticated) {
navigate("/", { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate]);
return <div>Loading Auth...</div>;
}

View File

@@ -0,0 +1,20 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom";
export default function AuthGuard() {
const auth = useAuth();
if (auth.isLoading) {
return <div>Loading...</div>;
}
if (auth.error) {
return <div>Auth Error: {auth.error.message}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,87 @@
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
import { useAuth } from "react-oidc-context";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
function LoginPage() {
const auth = useAuth();
const handleSSOLogin = () => {
// OIDC client-side authentication flow started here
auth.signinRedirect();
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Developer Control Plane
</p>
</div>
</div>
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
.<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,22 @@
import apiClient from "../../lib/apiClient";
export interface Tenant {
id: string;
name: string;
slug: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
role: string;
companyCode?: string;
tenantId?: string;
tenant?: Tenant;
}
export async function fetchMe() {
const { data } = await apiClient.get<UserProfile>("/user/me");
return data;
}

View File

@@ -211,14 +211,10 @@ function ClientsPage() {
variant={
item.tone === "up"
? "success"
: item.tone === "down"
? "warning"
: "muted"
: "muted"
}
className={cn(
"px-2",
item.tone === "down" &&
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
item.tone === "stable" && "bg-muted/40 text-foreground",
)}
>

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({
baseURL:
@@ -7,15 +8,15 @@ const apiClient = axios.create({
"/api/v1",
});
apiClient.interceptors.request.use((config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
const sessionToken = window.localStorage.getItem("admin_session");
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
apiClient.interceptors.request.use(async (config) => {
// OIDC Access Token 주입
const user = await userManager.getUser();
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
@@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
if (error.response?.status === 401) {
// 401 발생 시 로그인 페이지로 리다이렉트
const isAuthPath = window.location.pathname.startsWith("/callback");
if (!isAuthPath) {
userManager.signinRedirect();
}
}
return Promise.reject(error);
},
);

20
devfront/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client",
redirect_uri: `${window.location.origin}/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});

View File

@@ -9,6 +9,7 @@ export type ClientSummary = {
type: ClientType;
status: ClientStatus;
createdAt?: string;
clientSecret?: string;
redirectUris: string[];
scopes: string[];
};

View File

@@ -1,11 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import "./index.css";
import { oidcConfig } from "./lib/auth";
const rootElement = document.getElementById("root");
@@ -15,9 +17,11 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
);

View File

@@ -13,4 +13,7 @@ export default defineConfig({
},
},
},
esbuild: {
// drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
},
});

View File

@@ -10,6 +10,12 @@ serve:
admin:
base_url: http://localhost:4434/
session:
cookie:
domain: hmac.kr
same_site: Lax
path: /
selfservice:
default_browser_return_url: http://localhost:5000/
allowed_return_urls:

View File

@@ -0,0 +1,91 @@
# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고
이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다.
## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow)
### 시퀀스 다이어그램
```mermaid
sequenceDiagram
actor User
participant DF as DevFront (RP)
participant HY as Ory Hydra (OP)
participant UF as UserFront (Login UI)
participant KR as Ory Kratos (Identity)
User->>DF: 로그인 버튼 클릭
DF->>HY: 인증 요청 (/oauth2/auth)
HY->>UF: 로그인 UI 리다이렉트
UF->>User: 로그인 페이지 표시
User->>UF: 자격 증명 입력 (Email/PW)
UF->>KR: 인증 수행
KR-->>UF: 인증 성공 (Session 생성)
UF->>HY: 로그인 승인 요청
HY->>User: 권한 동의(Consent) 화면 표시
User->>HY: '허용' 클릭
HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...)
DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token)
HY-->>DF: 토큰 발급
Note over DF: [FIX] 백엔드 /api/me 호출 대신<br/>ID Token에서 프로필 정보 직접 추출
DF->>User: 대시보드 및 프로필 표시
```
### 단계별 설명
1. **인증 요청 (Login Request)**:
* 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다.
* `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다.
* 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다.
2. **사용자 인증 (Authentication)**:
* Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다.
* 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다.
3. **권한 동의 (Consent)**:
* 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다.
* 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다.
4. **인증 코드 전달 및 토큰 교환 (Callback)**:
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다.
* `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다.
5. **사용자 정보 로드 (Profile Recovery)**:
* `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다.
---
## 2. 클라이언트 자동 등록의 원리
사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다.
### `init-rp` 서비스의 역할
* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다.
* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다.
### 자동 등록된 `devfront` 명세
```bash
hydra clients create
--endpoint http://hydra:4445
--id devfront
--grant-types authorization_code,refresh_token
--response-types code
--scope openid,offline_access,profile,email
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
--callbacks http://localhost:5174/callback;
```
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다.
---
## 3. 무한 루프 문제 해결 분석
### 발생 원인 (Problem)
* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다.
* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다.
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다.
* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다.
### 해결 방법 (Solution)
* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다.
* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다.
* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer <token>` 헤더를 사용하여 정당한 권한을 증명합니다.

View File

@@ -1193,7 +1193,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 31),
color: statusColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
@@ -1203,9 +1203,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
),
style: TextStyle(
style: const TextStyle(
fontSize: 11,
color: statusColor,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),