package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "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]any{ "email": "user@baron.com", "name": "홍길동", "tenant_id": "primary-tenant-999", // Added primary tenant "tenant-1": map[string]any{ "department": "개발팀", "grade": "선임", }, "tenant-2": map[string]any{ "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.Equal(t, "primary-tenant-999", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) assert.Nil(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"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) assert.Nil(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.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) assert.Nil(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.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") }) t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) { claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1") assert.Equal(t, "tenant-1", claims["tenant_id"]) 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") }) } func TestRepresentativeTenantIDFromTraits(t *testing.T) { t.Run("explicit tenant_id wins", func(t *testing.T) { traits := map[string]any{ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", "additionalAppointments": []any{ map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", "isPrimary": true}, }, } assert.Equal(t, "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", representativeTenantIDFromTraits(traits)) }) t.Run("primary appointment wins when tenant_id is absent", func(t *testing.T) { traits := map[string]any{ "additionalAppointments": []any{ map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"}, map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630", "representative": true}, }, } assert.Equal(t, "01970f0c-8c44-7069-9f20-7d28c0b8e630", representativeTenantIDFromTraits(traits)) }) t.Run("first appointment is fallback", func(t *testing.T) { traits := map[string]any{ "additionalAppointments": []any{ map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"}, map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630"}, }, } assert.Equal(t, "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", representativeTenantIDFromTraits(traits)) }) } func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { var capturedClaims map[string]any 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]any{ "challenge": "challenge-dynamic", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", "metadata": map[string]any{ "tenant_id": "tenant-abc", }, }, }), nil } // Kratos: Get Identity if r.URL.Path == "/admin/identities/user-123" { return httpJSONAny(r, http.StatusOK, map[string]any{ "id": "user-123", "traits": map[string]any{ "email": "user@test.com", "name": "Test User", "tenant-abc": map[string]any{ "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]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "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]any{ "email": "user@test.com", "name": "Test User", "tenant-abc": map[string]any{ "department": "Innovation", "position": "Architect", }, }, }, nil) app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-dynamic", "grant_scope": []string{"openid", "profile", "tenant"}, }) 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 TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) { var capturedClaims map[string]any representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-representative-tenant", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-representative", "client": map[string]any{ "client_id": "client-app", "metadata": map[string]any{ "tenant_id": rpContextTenantID, }, }, }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { body, _ := io.ReadAll(r.Body) var acceptReq map[string]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "redirect_to": "http://rp/cb", }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, KratosAdmin: new(MockKratosAdminService), } h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{ ID: "user-representative", Traits: map[string]any{ "email": "user@test.com", "name": "Test User", "additionalAppointments": []any{ map[string]any{"tenantId": representativeTenantID, "isPrimary": true}, map[string]any{"tenantId": rpContextTenantID}, }, }, }, nil) app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-representative-tenant", "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) assert.NotNil(t, capturedClaims) assert.Equal(t, representativeTenantID, capturedClaims["tenant_id"]) assert.Contains(t, capturedClaims["joined_tenants"], representativeTenantID) assert.Contains(t, capturedClaims["joined_tenants"], rpContextTenantID) assert.Nil(t, capturedClaims["tenants"]) } func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) { var capturedClaims map[string]any deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" companyID := "01970f08-91da-7286-bd19-882fb98d1f2c" rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345" transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-hanmac-tenant-claim", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-hanmac", "client": map[string]any{ "client_id": "hanmac-rp", "metadata": map[string]any{ "tenant_id": deptID, }, }, }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { body, _ := io.ReadAll(r.Body) var acceptReq map[string]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "redirect_to": "http://rp/cb", }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, KratosAdmin: new(MockKratosAdminService), } h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{ ID: "user-hanmac", Traits: map[string]any{ "email": "hanmac-user@example.com", "name": "한맥 사용자", "additionalAppointments": []any{ map[string]any{ "tenantId": deptID, "isPrimary": true, "isOwner": true, "grade": "책임", "jobTitle": "기술기획", "position": "팀장", }, map[string]any{ "tenantId": secondDeptID, "isPrimary": false, "isOwner": false, "grade": "선임", "jobTitle": "품질관리", "position": "파트원", }, }, }, }, nil) mockTenantSvc := new(MockTenantService) mockTenantSvc.On("ListJoinedTenants", mock.Anything, "user-hanmac").Return([]domain.Tenant{}, nil) mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{ ID: deptID, Slug: "tech-planning", Name: "기술기획팀", Type: domain.TenantTypeUserGroup, ParentID: &companyID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{ ID: secondDeptID, Slug: "quality", Name: "품질관리팀", Type: domain.TenantTypeUserGroup, ParentID: &companyID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{ ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{ ID: rootID, Slug: "hanmac-family", Name: "한맥가족", Type: domain.TenantTypeCompanyGroup, }, nil) h.TenantService = mockTenantSvc app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-hanmac-tenant-claim", "grant_scope": []string{"openid", "profile", "tenant"}, }) 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) assert.NotNil(t, capturedClaims) assert.Equal(t, []any{deptID}, capturedClaims["lead_tenants"]) assert.ElementsMatch(t, []any{deptID, secondDeptID}, capturedClaims["joined_tenants"]) tenants := capturedClaims["tenants"].(map[string]any) dept := tenants[deptID].(map[string]any) assert.Equal(t, true, dept["lead"]) assert.Equal(t, true, dept["representative"]) assert.Equal(t, "책임", dept["grade"]) assert.Equal(t, "기술기획", dept["jobTitle"]) assert.Equal(t, "팀장", dept["position"]) assert.Equal(t, companyID, dept["parentTenantId"]) assert.NotContains(t, dept, "parentTenant") ancestors := dept["ancestors"].([]any) assert.Len(t, ancestors, 2) companyAncestor := ancestors[0].(map[string]any) assert.Equal(t, companyID, companyAncestor["id"]) assert.Equal(t, "hanmac", companyAncestor["slug"]) assert.Equal(t, rootID, companyAncestor["parentTenantId"]) assert.NotContains(t, companyAncestor, "parentTenant") rootAncestor := ancestors[1].(map[string]any) assert.Equal(t, rootID, rootAncestor["id"]) assert.Equal(t, "hanmac-family", rootAncestor["slug"]) assert.Contains(t, rootAncestor, "parentTenantId") assert.Nil(t, rootAncestor["parentTenantId"]) assert.NotContains(t, rootAncestor, "parentTenant") secondDept := tenants[secondDeptID].(map[string]any) assert.Equal(t, false, secondDept["lead"]) assert.Equal(t, false, secondDept["representative"]) assert.Equal(t, "선임", secondDept["grade"]) assert.Equal(t, "품질관리", secondDept["jobTitle"]) assert.Equal(t, "파트원", secondDept["position"]) assert.Equal(t, companyID, secondDept["parentTenantId"]) } func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *testing.T) { deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" companyID := "01970f08-91da-7286-bd19-882fb98d1f2c" rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345" mockTenantSvc := new(MockTenantService) mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{ ID: deptID, Slug: "tech-planning", Name: "기술기획팀", Type: domain.TenantTypeUserGroup, ParentID: &companyID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{ ID: secondDeptID, Slug: "quality", Name: "품질관리팀", Type: domain.TenantTypeUserGroup, ParentID: &companyID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{ ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{ ID: rootID, Slug: "hanmac-family", Name: "한맥가족", Type: domain.TenantTypeCompanyGroup, }, nil) h := &AuthHandler{TenantService: mockTenantSvc} claims := map[string]any{"tenant_id": deptID} traits := map[string]any{ "additionalAppointments": []any{ map[string]any{ "tenantId": deptID, "isPrimary": true, "isOwner": true, "grade": "책임", }, map[string]any{ "tenantId": secondDeptID, "grade": "선임", }, }, } claims = h.withHanmacFamilyTenantClaims(context.Background(), claims, traits, []string{"openid", "profile"}) assert.Equal(t, deptID, claims["tenant_id"]) assert.ElementsMatch(t, []string{deptID, secondDeptID}, claims["joined_tenants"]) assert.NotContains(t, claims, "tenants") assert.NotContains(t, claims, "lead_tenants") } func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) { var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-rp-profile", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", "metadata": map[string]any{ "customUserSchema": []map[string]any{ { "key": "approvalLevel", "label": "승인 등급", "type": "text", "claimEnabled": true, }, { "key": "internalMemo", "label": "내부 메모", "type": "text", "claimEnabled": false, }, }, }, }, }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { body, _ := io.ReadAll(r.Body) var acceptReq map[string]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "redirect_to": "http://rp/cb", }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} 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]any{ "email": "user@test.com", "name": "Test User", }, }, nil) repo := new(devMockRPUserMetadataRepo) repo.On("Get", mock.Anything, "client-app", "user-123").Return(&domain.RPUserMetadata{ ClientID: "client-app", UserID: "user-123", Metadata: domain.JSONMap{ "approvalLevel": "A", "internalMemo": "관리자 전용", }, }, nil).Maybe() h.RPUserMetadataRepo = repo app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-rp-profile", "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) assert.NotNil(t, capturedClaims) assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest()) repo.AssertExpectations(t) } func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { var capturedClaims map[string]any 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]any{ "challenge": "challenge-skip-dynamic", "requested_scope": []string{"openid", "profile", "tenant"}, "skip": true, "subject": "user-456", "client": map[string]any{ "client_id": "skip-app", "metadata": map[string]any{ "tenant_id": "tenant-xyz", }, }, }), nil } // Kratos: Get Identity if r.URL.Path == "/admin/identities/user-456" { return httpJSONAny(r, http.StatusOK, map[string]any{ "id": "user-456", "traits": map[string]any{ "email": "skip@test.com", "tenant-xyz": map[string]any{ "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]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "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]any{ "email": "skip@test.com", "tenant-xyz": map[string]any{ "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"]) } func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) { claims := buildOidcClaimsFromTraits(map[string]any{ "email": "user@test.com", "name": "Test User", "global_custom_claims": map[string]any{ "contract_date": "2026-06-09", "approved_at": "2026-06-09T09:30:00+09:00", "email": "override@test.com", "rp_claims": "reserved", }, "global_custom_claim_permissions": map[string]any{ "contract_date": map[string]any{ "readPermission": "user_and_admin", "writePermission": "admin_only", }, }, }, []string{"openid", "profile", "email"}, "") assert.Equal(t, "2026-06-09", claims["contract_date"]) assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"]) assert.Equal(t, "user@test.com", claims["email"]) assert.NotEqual(t, "reserved", claims["rp_claims"]) assert.NotContains(t, claims, "global_custom_claim_permissions") } func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-configured-claims", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-789", "client": map[string]any{ "client_id": "client-configured-claims", "metadata": map[string]any{ "tenant_id": "tenant-claims", "id_token_claims": []map[string]any{ { "namespace": "top_level", "key": "locale", "value": "ko-KR", "valueType": "text", }, { "namespace": "top_level", "key": "email", "value": "should-not-override@example.com", "valueType": "text", }, { "namespace": "rp_claims", "key": "tier", "value": "2", "valueType": "number", }, { "namespace": "rp_claims", "key": "features", "value": "[\"sso\",\"claims\"]", "valueType": "array", }, }, }, }, }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" { body, _ := io.ReadAll(r.Body) var acceptReq map[string]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "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-789").Return(&service.KratosIdentity{ ID: "user-789", Traits: map[string]any{ "email": "real-user@example.com", "name": "Configured User", "tenant-claims": map[string]any{ "department": "Platform", }, }, }, nil) app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-configured-claims", "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) assert.NotNil(t, capturedClaims) assert.Equal(t, "real-user@example.com", capturedClaims["email"]) assert.Equal(t, "ko-KR", capturedClaims["locale"]) assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"]) rpClaims, ok := capturedClaims["rp_claims"].(map[string]any) if assert.True(t, ok) { tier := rpClaims["tier"].(map[string]any) assert.Equal(t, float64(2), tier["value"]) assert.Equal(t, "admin_only", tier["readPermission"]) assert.Equal(t, "admin_only", tier["writePermission"]) features := rpClaims["features"].(map[string]any) assert.Equal(t, []any{"sso", "claims"}, features["value"]) } } func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) { var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-rp-user-claims", "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-rp-claims", "client": map[string]any{ "client_id": "client-rp-claims", "metadata": map[string]any{ "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "approvalLevel", "value": "A", "valueType": "text", }, { "namespace": "rp_claims", "key": "activeMember", "value": "true", "valueType": "boolean", }, { "namespace": "rp_claims", "key": "score", "value": "1", "valueType": "number", }, { "namespace": "rp_claims", "key": "featureList", "value": `["default"]`, "valueType": "array", }, { "namespace": "rp_claims", "key": "preferences", "value": `{"theme":"light","density":"comfortable"}`, "valueType": "object", }, { "namespace": "rp_claims", "key": "contractDate", "value": "2026-06-09", "valueType": "date", }, { "namespace": "rp_claims", "key": "approvedAt", "value": "2026-06-09T09:30", "valueType": "datetime", }, { "namespace": "rp_claims", "key": "tenants", "value": "must-not-shadow-tenants", "valueType": "text", "readPermission": "user_and_admin", "writePermission": "user_and_admin", }, }, }, }, }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" { body, _ := io.ReadAll(r.Body) var acceptReq map[string]any json.Unmarshal(body, &acceptReq) if session, ok := acceptReq["session"].(map[string]any); ok { capturedClaims = session["id_token"].(map[string]any) } return httpJSONAny(r, http.StatusOK, map[string]any{ "redirect_to": "http://rp/cb", }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, KratosAdmin: new(MockKratosAdminService), } h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{ ID: "user-rp-claims", Traits: map[string]any{ "email": "rp-user@example.com", "name": "RP User", "tenant_id": "tenant-leaf", "tenant-leaf": map[string]any{ "department": "Platform", "rp_claims": map[string]any{"mustNotLeak": true}, "rp_custom_claims": map[string]any{"client-rp-claims": map[string]any{"mustNotLeak": true}}, }, "rp_custom_claims": map[string]any{ "client-rp-claims": map[string]any{"approvalLevel": "B"}, }, }, }, nil) rootTenantID := "tenant-root" mockTenantSvc := new(MockTenantService) mockTenantSvc.On("GetTenant", mock.Anything, "tenant-leaf").Return(&domain.Tenant{ ID: "tenant-leaf", Slug: "platform", Name: "플랫폼팀", Type: domain.TenantTypeUserGroup, ParentID: &rootTenantID, }, nil) mockTenantSvc.On("GetTenant", mock.Anything, rootTenantID).Return(&domain.Tenant{ ID: rootTenantID, Slug: "root", Name: "루트", Type: domain.TenantTypeCompany, }, nil) mockTenantSvc.On("ListJoinedTenants", mock.Anything, "user-rp-claims").Return([]domain.Tenant{}, nil) h.TenantService = mockTenantSvc repo := new(devMockRPUserMetadataRepo) repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{ ClientID: "client-rp-claims", UserID: "user-rp-claims", Metadata: domain.JSONMap{ "approvalLevel": "B", "activeMember": false, "score": float64(42), "featureList": []any{"sso", "claims"}, "preferences": map[string]any{ "theme": "dark", "density": "compact", }, "contractDate": float64(1781017200), "approvedAt": float64(1780968600), "internalMemo": "must-not-leak", "approvalLevel_permissions": map[string]any{ "readPermission": "admin_only", "writePermission": "user_and_admin", }, }, }, nil).Once() h.RPUserMetadataRepo = repo app := fiber.New() app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-rp-user-claims", "grant_scope": []string{"openid", "profile", "tenant"}, }) 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) assert.NotNil(t, capturedClaims) rpClaims, ok := capturedClaims["rp_claims"].(map[string]any) if assert.True(t, ok) { approvalLevel := rpClaims["approvalLevel"].(map[string]any) assert.Equal(t, "B", approvalLevel["value"]) assert.Equal(t, "user_and_admin", approvalLevel["readPermission"]) assert.Equal(t, "user_and_admin", approvalLevel["writePermission"]) activeMember := rpClaims["activeMember"].(map[string]any) assert.Equal(t, false, activeMember["value"]) score := rpClaims["score"].(map[string]any) assert.Equal(t, float64(42), score["value"]) featureList := rpClaims["featureList"].(map[string]any) assert.Equal(t, []any{"sso", "claims"}, featureList["value"]) preferences := rpClaims["preferences"].(map[string]any) assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, preferences["value"]) contractDate := rpClaims["contractDate"].(map[string]any) assert.Equal(t, float64(1781017200), contractDate["value"]) approvedAt := rpClaims["approvedAt"].(map[string]any) assert.Equal(t, float64(1780968600), approvedAt["value"]) assert.NotContains(t, rpClaims, "tenants") assert.NotContains(t, rpClaims, "internalMemo") assert.NotContains(t, rpClaims, "approvalLevel_permissions") } assert.NotContains(t, capturedClaims["joined_tenants"], "rp_custom_claims") tenants := capturedClaims["tenants"].(map[string]any) assert.Contains(t, tenants, "tenant-leaf") assert.NotEqual(t, "must-not-shadow-tenants", capturedClaims["tenants"]) assertNoRPClaimDataInTenantClaims(t, tenants) assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest()) repo.AssertExpectations(t) } func legacyProfileArrayClaimKeyForTest() string { return strings.Join([]string{"rp", "profiles"}, "_") } func assertNoRPClaimDataInTenantClaims(t *testing.T, value any) { t.Helper() switch typed := value.(type) { case map[string]any: for key, child := range typed { assert.False(t, strings.HasPrefix(key, "rp_")) assertNoRPClaimDataInTenantClaims(t, child) } case []any: for _, child := range typed { assertNoRPClaimDataInTenantClaims(t, child) } } }