diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3c3ea26f..4bb4fd33 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -990,7 +990,7 @@ func normalizePhoneForLoginID(phone string) string { return normalized } -func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any { +func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any { claims := map[string]any{} if traits == nil { return claims @@ -1092,9 +1092,58 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[strin } } + // [New] Dynamic Claim Injection for Multi-tenancy + if tenantID != "" { + claims["tenant_id"] = tenantID + // Extract namespaced metadata if available + // The key in traits is expected to be the tenantID + if namespaced, ok := traits[tenantID].(map[string]any); ok { + for k, v := range namespaced { + claims[k] = v + } + } else if namespaced, ok := traits[tenantID].(map[string]interface{}); ok { + for k, v := range namespaced { + claims[k] = v + } + } + } + + // [Update] Pass ALL tenants the user belongs to + allTenants := map[string]any{} + var joinedTenants []string + + // Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant + for k, v := range traits { + if m, ok := v.(map[string]any); ok { + allTenants[k] = m + joinedTenants = append(joinedTenants, k) + } else if m, ok := v.(map[string]interface{}); ok { + allTenants[k] = m + joinedTenants = append(joinedTenants, k) + } + } + + // [Fix] Include primary tenant_id in joined_tenants if it's not already there + if primaryTenantID := getString("tenant_id"); primaryTenantID != "" { + found := false + for _, id := range joinedTenants { + if id == primaryTenantID { + found = true + break + } + } + if !found { + joinedTenants = append(joinedTenants, primaryTenantID) + } + } + + if len(allTenants) > 0 || len(joinedTenants) > 0 { + claims["tenants"] = allTenants + claims["joined_tenants"] = joinedTenants + } + return claims } - func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { if claims == nil { claims = map[string]any{} @@ -2957,6 +3006,12 @@ func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { clientName = clientID } + if client.Metadata != nil { + if tid, ok := client.Metadata["tenant_id"].(string); ok && tid != "" { + c.Locals("tenant_id", tid) + } + } + c.Locals("audit_details_extra", map[string]any{ "client_id": clientID, "client_name": clientName, @@ -5081,10 +5136,23 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { + var tenantID string + if consentRequest.Client.Metadata != nil { + if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { + tenantID = tid + } + } + sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), h.resolveCurrentSessionID(c), ) + + // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON)) + } + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err) @@ -5099,6 +5167,35 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { } _ = h.ConsentRepo.Upsert(c.Context(), consent) } + + if h.AuditRepo != nil { + detailsMap := map[string]interface{}{ + "client_id": consentRequest.Client.ClientID, + "scopes": consentRequest.RequestedScope, + "client_name": consentRequest.Client.ClientName, + } + currentSessionID := h.resolveCurrentSessionID(c) + if currentSessionID != "" { + detailsMap["session_id"] = currentSessionID + detailsMap["approved_session_id"] = currentSessionID + } + detailsMap["auto_accepted"] = true + detailsBytes, _ := json.Marshal(detailsMap) + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: GenerateSecureToken(16), + Timestamp: time.Now(), + UserID: consentRequest.Subject, + TenantID: tenantID, // Uses the tenantID extracted earlier + SessionID: currentSessionID, + EventType: "consent.granted", + Status: "success", + IPAddress: c.IP(), + UserAgent: string(c.Request().Header.UserAgent()), + Details: string(detailsBytes), + }) + } + slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID) return c.JSON(acceptResp) } @@ -5205,11 +5302,24 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { c.Locals("login_id", loginID) } currentSessionID := h.resolveCurrentSessionID(c) + + var tenantID string + if consentRequest.Client.Metadata != nil { + if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { + tenantID = tid + } + } + sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), currentSessionID, ) + // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON)) + } + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to accept hydra consent request", "error", err) @@ -5245,6 +5355,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, + TenantID: tenantID, // [New] Add TenantID to AuditLog SessionID: currentSessionID, EventType: "consent.granted", Status: "success", diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go new file mode 100644 index 00000000..9628d54f --- /dev/null +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -0,0 +1,268 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { + traits := map[string]interface{}{ + "email": "user@baron.com", + "name": "홍길동", + "tenant_id": "primary-tenant-999", // Added primary tenant + "tenant-1": map[string]interface{}{ + "department": "개발팀", + "grade": "선임", + }, + "tenant-2": map[string]interface{}{ + "department": "재무팀", + "grade": "팀장", + }, + } + scopes := []string{"openid", "profile"} + + t.Run("No tenantID", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "tenant-2") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") // Should contain primary + }) + + t.Run("With tenant-1", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-1", claims["tenant_id"]) // Dynamic tenant injection overwrites top-level for this context + assert.Equal(t, "개발팀", claims["department"]) + assert.Equal(t, "선임", claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "tenant-2") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) + + t.Run("With tenant-2", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-2", claims["tenant_id"]) + assert.Equal(t, "재무팀", claims["department"]) + assert.Equal(t, "팀장", claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) + + t.Run("With non-existent tenant", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-3", claims["tenant_id"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) +} + +func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { + var capturedClaims map[string]interface{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-dynamic", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-abc", + }, + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + "name": "Test User", + "tenant-abc": map[string]interface{}{ + "department": "Innovation", + "position": "Architect", + }, + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { + // Capture the claims sent to Hydra + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + ID: "user-123", + Traits: map[string]interface{}{ + "email": "user@test.com", + "name": "Test User", + "tenant-abc": map[string]interface{}{ + "department": "Innovation", + "position": "Architect", + }, + }, + }, nil) + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-dynamic", + "grant_scope": []string{"openid", "profile"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify captured claims + assert.NotNil(t, capturedClaims) + assert.Equal(t, "user@test.com", capturedClaims["email"]) + assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"]) + assert.Equal(t, "Innovation", capturedClaims["department"]) + assert.Equal(t, "Architect", capturedClaims["position"]) +} + +func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { + var capturedClaims map[string]interface{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-skip-dynamic", + "requested_scope": []string{"openid", "profile"}, + "skip": true, + "subject": "user-456", + "client": map[string]interface{}{ + "client_id": "skip-app", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-xyz", + }, + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-456" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-456", + "traits": map[string]interface{}{ + "email": "skip@test.com", + "tenant-xyz": map[string]interface{}{ + "department": "Security", + "position": "Officer", + }, + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { + // Capture the claims sent to Hydra + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{ + ID: "user-456", + Traits: map[string]interface{}{ + "email": "skip@test.com", + "tenant-xyz": map[string]interface{}{ + "department": "Security", + "position": "Officer", + }, + }, + }, nil) + + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify captured claims + assert.NotNil(t, capturedClaims) + assert.Equal(t, "skip@test.com", capturedClaims["email"]) + assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"]) + assert.Equal(t, "Security", capturedClaims["department"]) + assert.Equal(t, "Officer", capturedClaims["position"]) +} +