From 11ce54172ffd3fb4334e115b0a7bbdf7c2f364be Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 12 Feb 2026 13:22:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B8=8C=EB=9F=B0=EC=B9=98=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20devfront=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 +- backend/internal/handler/auth_handler.go | 46 ++++++++++++------- .../internal/service/hydra_admin_service.go | 31 +++++++++++++ devfront/src/lib/apiClient.ts | 8 ++-- devfront/src/lib/auth.ts | 2 +- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5978a0d0..b5dd9fc9 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -554,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/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index efbc5676..1be8ecdb 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4849,26 +4849,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/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index df497204..ce9c97bc 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -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/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index d379910f..fd7cf54f 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -28,9 +28,11 @@ apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - // 401 발생 시 로그인 페이지로 리다이렉트하거나 토큰 갱신 로직 필요 - // 여기서는 간단히 리다이렉트 처리 (userManager 사용) - userManager.signinRedirect(); + // 401 발생 시 로그인 페이지로 리다이렉트 + const isAuthPath = window.location.pathname.startsWith("/callback"); + if (!isAuthPath) { + userManager.signinRedirect(); + } } return Promise.reject(error); }, diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index c9582c9c..6ce22a23 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -2,7 +2,7 @@ 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:3000/api/v1/auth/oidc", // Backend Proxy URL + 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",