diff --git a/.env.sample b/.env.sample
index 463b0415..bd8f46b3 100644
--- a/.env.sample
+++ b/.env.sample
@@ -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
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 4e8fabf7..b5dd9fc9 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -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,
})
diff --git a/backend/internal/bootstrap/kratos_seed.go b/backend/internal/bootstrap/kratos_seed.go
index 7ddb4a4c..b1205cb5 100644
--- a/backend/internal/bootstrap/kratos_seed.go
+++ b/backend/internal/bootstrap/kratos_seed.go
@@ -34,6 +34,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error {
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
+ "role": domain.RoleSuperAdmin,
},
}
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 5903c80a..66ea4ebb 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -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) {
diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go
index 67afe9da..a7f08f5a 100644
--- a/backend/internal/middleware/rbac.go
+++ b/backend/internal/middleware/rbac.go
@@ -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": "요청하신 리소스에 접근할 수 없습니다."})
}
}
diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go
index ef1e0911..ce9c97bc 100644
--- a/backend/internal/service/hydra_admin_service.go
+++ b/backend/internal/service/hydra_admin_service.go
@@ -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
+}
diff --git a/devfront/package-lock.json b/devfront/package-lock.json
index 460860c8..54d01aab 100644
--- a/devfront/package-lock.json
+++ b/devfront/package-lock.json
@@ -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",
diff --git a/devfront/package.json b/devfront/package.json
index cf724b45..a8c53649 100644
--- a/devfront/package.json
+++ b/devfront/package.json
@@ -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"
diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx
index 0bbba406..7da4c279 100644
--- a/devfront/src/app/routes.tsx
+++ b/devfront/src/app/routes.tsx
@@ -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:
+ Developer Control Plane +
+
+ 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
+ 민감한 작업 시 재인증을 요구할 수 있습니다.
+
+ 인증 정보가 없거나 로그인이 되지 않는 경우
+ 시스템 관리자에게 문의하세요.
+