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_WRITE_URL=http://keto:4467
# KETO_READ_PORT=4466 # Internal only # KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # 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) # Kratos Selfservice UI upstreams (override for deployments)
ORY_SDK_URL=http://kratos:4433 ORY_SDK_URL=http://kratos:4433

View File

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

View File

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

View File

@@ -1526,7 +1526,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
loginID := strings.TrimSpace(req.LoginID) loginID := strings.TrimSpace(req.LoginID)
ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId"] = req.LoginID // 원문
ale.LoginIDs["loginId_normalized"] = 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") ale.Log(slog.LevelInfo, "Attempting to login")
@@ -1568,11 +1568,13 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 --- // --- OIDC 로그인 흐름 처리 ---
if req.LoginChallenge != "" { 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 // Check if the client is active
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { 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 status, ok := loginReq.Client.Metadata["status"].(string); ok {
if strings.ToLower(status) == "inactive" { if strings.ToLower(status) == "inactive" {
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
@@ -1580,10 +1582,11 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
} }
} }
} }
}
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil { 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") return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
} }
slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo) slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo)
@@ -1595,6 +1598,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
resp := fiber.Map{ resp := fiber.Map{
"sessionToken": authInfo.SessionToken.JWT, "sessionToken": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility
"status": "ok", "status": "ok",
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
} }
@@ -2480,7 +2484,56 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
return phone 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 { func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
profile, err := h.resolveCurrentProfile(c) profile, err := h.resolveCurrentProfile(c)
if err != nil { if err != nil {
@@ -4797,26 +4850,40 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
req.Header.Set("X-Session-Token", sessionToken) req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err == nil {
return "", nil, err
}
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode == http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct { var result struct {
Identity struct { Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
return "", nil, err return result.Identity.ID, result.Identity.Traits, nil
}
}
} }
return result.Identity.ID, result.Identity.Traits, nil // 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 "", nil, fmt.Errorf("invalid session or token")
} }
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {

View File

@@ -3,6 +3,7 @@ package middleware
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"fmt"
"log/slog" "log/slog"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -25,7 +26,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
profile, err := config.AuthHandler.GetEnrichedProfile(c) profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil { 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 // Store profile in locals for further use in handlers
@@ -43,7 +44,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
} }
if objectID == "" { 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 // 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) allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed { if err != nil || !allowed {
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) 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() return c.Next()
@@ -73,7 +76,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
profile, err := config.AuthHandler.GetEnrichedProfile(c) profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil { if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ 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(), "path", c.Path(),
) )
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ 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) profile, err := config.AuthHandler.GetEnrichedProfile(c)
if err != nil { 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 // 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 { if profile.TenantID == nil || *profile.TenantID != targetTenantID {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ 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.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 return nil, err
} }
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -75,7 +75,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
return nil, err return nil, err
} }
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err 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") req.Header.Set("Content-Type", "application/json-patch+json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -145,7 +145,7 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.Hydr
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -174,7 +174,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -202,7 +202,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e
return err return err
} }
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return err return err
} }
@@ -235,7 +235,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
return nil, err return nil, err
} }
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -276,7 +276,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
return err return err
} }
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return err return err
} }
@@ -289,7 +289,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
return nil return nil
} }
func (s *HydraAdminService) httpClient() *http.Client { func (s *HydraAdminService) HttpClient() *http.Client {
if s.HTTPClient != nil { if s.HTTPClient != nil {
return s.HTTPClient 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) 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 { if err != nil {
return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err) 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") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err) 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") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err) 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) 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 { if err != nil {
return nil, fmt.Errorf("hydra admin: get login request failed: %w", err) 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") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err) 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") req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req) resp, err := s.HttpClient().Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err) 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 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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^3.24.1" "zod": "^3.24.1"
@@ -2332,6 +2334,15 @@
"node": ">=6" "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": { "node_modules/lightningcss": {
"version": "1.31.1", "version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -2774,6 +2785,18 @@
"node": ">= 6" "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": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3095,6 +3118,19 @@
"react": "^16.8.0 || ^17 || ^18 || ^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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^3.24.1" "zod": "^3.24.1"

View File

@@ -1,5 +1,8 @@
import { Navigate, createBrowserRouter } from "react-router-dom"; import { Navigate, createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout"; 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 ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage";
@@ -7,8 +10,19 @@ import ClientsPage from "../features/clients/ClientsPage";
export const router = createBrowserRouter( export const router = createBrowserRouter(
[ [
{
path: "/login",
element: <LoginPage />,
},
{
path: "/callback",
element: <AuthCallbackPage />,
},
{ {
path: "/", path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />, element: <AppLayout />,
children: [ children: [
{ index: true, element: <Navigate to="/clients" replace /> }, { index: true, element: <Navigate to="/clients" replace /> },
@@ -20,6 +34,8 @@ export const router = createBrowserRouter(
], ],
}, },
], ],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅) // React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{ {
future: { future: {

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 { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { Toaster } from "../ui/toaster"; import { Toaster } from "../ui/toaster";
@@ -14,6 +15,18 @@ const navItems = [
]; ];
function AppLayout() { function AppLayout() {
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 [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light"; return stored === "dark" ? "dark" : "light";
@@ -34,6 +47,17 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light")); 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 ( return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]"> <div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur"> <aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
@@ -61,6 +85,11 @@ function AppLayout() {
<span className="rounded-full border border-border px-3 py-1"> <span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")} {t("ui.dev.env_badge", "Env: dev")}
</span> </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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( {navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
@@ -116,6 +145,16 @@ function AppLayout() {
? t("ui.common.theme_light", "Light") ? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")} : t("ui.common.theme_dark", "Dark")}
</button> </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>
</div> </div>
</header> </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 { cn } from "../../lib/utils";
import { useToastState } from "./use-toast"; 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={ variant={
item.tone === "up" item.tone === "up"
? "success" ? "success"
: item.tone === "down"
? "warning"
: "muted" : "muted"
} }
className={cn( className={cn(
"px-2", "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", item.tone === "stable" && "bg-muted/40 text-foreground",
)} )}
> >

View File

@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: baseURL:
@@ -7,15 +8,15 @@ const apiClient = axios.create({
"/api/v1", "/api/v1",
}); });
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use(async (config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다. // OIDC Access Token 주입
const sessionToken = window.localStorage.getItem("admin_session"); const user = await userManager.getUser();
if (sessionToken) { if (user?.access_token) {
config.headers.Authorization = `Bearer ${sessionToken}`; config.headers.Authorization = `Bearer ${user.access_token}`;
} }
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다. // TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant"); const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
if (tenantId) { if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId; config.headers["X-Tenant-ID"] = tenantId;
} }
@@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (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); 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; type: ClientType;
status: ClientStatus; status: ClientStatus;
createdAt?: string; createdAt?: string;
clientSecret?: string;
redirectUris: string[]; redirectUris: string[];
scopes: string[]; scopes: string[];
}; };

View File

@@ -1,11 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient"; import { queryClient } from "./app/queryClient";
import { router } from "./app/routes"; import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster"; import { Toaster } from "./components/ui/toaster";
import "./index.css"; import "./index.css";
import { oidcConfig } from "./lib/auth";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@@ -15,9 +17,11 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster /> <Toaster />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider>
</StrictMode>, </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: admin:
base_url: http://localhost:4434/ base_url: http://localhost:4434/
session:
cookie:
domain: hmac.kr
same_site: Lax
path: /
selfservice: selfservice:
default_browser_return_url: http://localhost:5000/ default_browser_return_url: http://localhost:5000/
allowed_return_urls: 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withValues(alpha: 31), color: statusColor,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
@@ -1203,9 +1203,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'ui.userfront.dashboard.status.revoked', 'ui.userfront.dashboard.status.revoked',
fallback: '해지됨', fallback: '해지됨',
), ),
style: TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: statusColor, color: Colors.white,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),