diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index dae3e471..f0294cd0 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1040,7 +1040,7 @@ func normalizePhoneForLoginID(phone string) string { return domain.NormalizePhoneNumber(phone) } -func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any { +func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string, profileStatus string) map[string]any { claims := map[string]any{} if traits == nil { return claims @@ -1089,28 +1089,27 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID if _, ok := scopeSet["profile"]; ok { profile := map[string]any{} - names := map[string]any{} - for _, key := range []string{ - "name", - "displayname", - "preferred_username", - "given_name", - "family_name", - "middle_name", - "nickname", - } { - if value := getString(key); value != "" { - names[key] = value + displayProfileName := displayName + if displayProfileName != "" { + profile["name"] = displayProfileName + } + if primaryEmail != "" { + profile["email"] = primaryEmail + } + if secondaryEmails := collectStringValues(traits, "sub_email", "secondary_emails", "additional_emails", "aliasEmails", "worksmobileAliasEmails"); len(secondaryEmails) > 0 { + profile["secondary_emails"] = secondaryEmails + } + if phone := getString("phone_number"); phone != "" { + profile["phones"] = []string{phone} + } + if employeeID := getString("employee_id"); employeeID != "" { + profile["employee_id"] = employeeID + } + if trimmedStatus := strings.TrimSpace(profileStatus); trimmedStatus != "" { + if normalizedStatus := strings.TrimSpace(domain.NormalizeUserStatus(trimmedStatus)); normalizedStatus != "" { + profile["status"] = normalizedStatus } } - if len(names) > 0 { - profile["names"] = names - } - - emails := collectEmailList(traits, primaryEmail) - if len(emails) > 0 { - profile["emails"] = emails - } if len(profile) > 0 { claims["profile"] = profile } @@ -1215,13 +1214,63 @@ func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[ return claims } -func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any { - claims := buildOidcClaimsFromTraits(traits, scopes, tenantID) +func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string, profileStatus string) map[string]any { + claims := buildOidcClaimsFromTraits(traits, scopes, tenantID, profileStatus) claims = applyConfiguredIDTokenClaims(claims, client.Metadata) claims = withRefreshTokenExpiryClaim(claims, time.Now()) return withOidcSessionMetadata(claims, sessionID) } +func collectStringValues(traits map[string]any, keys ...string) []string { + values := make([]string, 0) + seen := make(map[string]struct{}) + add := func(raw string) { + value := strings.TrimSpace(raw) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + values = append(values, value) + } + for _, key := range keys { + raw, ok := traits[key] + if !ok || raw == nil { + continue + } + switch value := raw.(type) { + case string: + add(value) + case []string: + for _, item := range value { + add(item) + } + case []any: + for _, item := range value { + add(fmt.Sprint(item)) + } + } + } + return values +} + +func (h *AuthHandler) resolveProfileStatus(ctx context.Context, subject string) string { + if h == nil || h.UserRepo == nil { + return "" + } + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + user, err := h.UserRepo.FindByID(ctx, subject) + if err != nil || user == nil { + return "" + } + return domain.NormalizeUserStatus(user.Status) +} + func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any { if baseClaims == nil { baseClaims = map[string]any{} @@ -1364,7 +1413,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m func tenantClaimScopeRequested(scopes []string) bool { for _, scope := range scopes { - if strings.EqualFold(strings.TrimSpace(scope), "tenant") { + if strings.EqualFold(strings.TrimSpace(scope), "tenants") || strings.EqualFold(strings.TrimSpace(scope), "tenant") { return true } } @@ -6295,6 +6344,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -6333,6 +6383,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -6524,6 +6575,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index dcb368b6..b0b7eb43 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -203,7 +203,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { "allowed_tenants": []string{"tenant-allow"}, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, @@ -262,9 +262,9 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { var body map[string]any json.NewDecoder(resp.Body).Decode(&body) - assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"]) + assert.Equal(t, []any{"openid", "tenants", "profile"}, body["requested_scope"]) scopeDetails := body["scope_details"].(map[string]any) - tenantDetail := scopeDetails["tenant"].(map[string]any) + tenantDetail := scopeDetails["tenants"].(map[string]any) assert.Equal(t, true, tenantDetail["mandatory"]) } @@ -448,7 +448,7 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { "allowed_tenants": []string{"tenant-abc"}, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, @@ -511,5 +511,5 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { resp, err := app.Test(req) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes) + assert.Equal(t, []string{"openid", "tenants", "profile"}, capturedGrantScopes) } diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index f634f568..2ee5a025 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -70,9 +70,12 @@ func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) { func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { traits := map[string]any{ - "email": "user@baron.com", - "name": "홍길동", - "tenant_id": "primary-tenant-999", // Added primary tenant + "email": "user@baron.com", + "name": "홍길동", + "phone_number": "+821012345678", + "employee_id": "EMP-001", + "sub_email": []any{"alias1@baron.com", "alias2@baron.com"}, + "tenant_id": "primary-tenant-999", // Added primary tenant "tenant-1": map[string]any{ "department": "개발팀", "grade": "선임", @@ -85,12 +88,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { scopes := []string{"openid", "profile"} t.Run("No tenantID", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "") + claims := buildOidcClaimsFromTraits(traits, scopes, "", "leave_of_absence") 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"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") @@ -99,12 +109,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("With tenant-1", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1", "leave_of_absence") 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"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") @@ -113,35 +130,56 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("With tenant-2", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2", "leave_of_absence") 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"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) 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") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3", "leave_of_absence") 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"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) 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") + t.Run("Tenants scope includes detailed tenant metadata", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1", "leave_of_absence") assert.Equal(t, "tenant-1", claims["tenant_id"]) assert.Equal(t, "개발팀", claims["department"]) assert.Equal(t, "선임", claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.NotNil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "tenant-2") @@ -190,7 +228,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", @@ -260,7 +298,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-dynamic", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -290,7 +328,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-representative", "client": map[string]any{ "client_id": "client-app", @@ -367,7 +405,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing. 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-hanmac", "client": map[string]any{ "client_id": "hanmac-rp", @@ -462,7 +500,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing. reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-hanmac-tenant-claim", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -574,7 +612,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) { 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", @@ -666,7 +704,7 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "skip": true, "subject": "user-456", "client": map[string]any{ @@ -845,7 +883,7 @@ func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) { "writePermission": "admin_only", }, }, - }, []string{"openid", "profile", "email"}, "") + }, []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"]) @@ -861,7 +899,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-789", "client": map[string]any{ "client_id": "client-configured-claims", @@ -973,7 +1011,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) 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"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-rp-claims", "client": map[string]any{ "client_id": "client-rp-claims", @@ -1119,7 +1157,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-rp-user-claims", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index a5f9bbe3..7d2fb95c 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -463,7 +463,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { out := make([]string, 0, len(combined)) appendIfPresent := func(scope string) { - scope = strings.TrimSpace(scope) + scope = canonicalConsentScopeName(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { return } @@ -471,7 +471,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { return } for _, candidate := range combined { - if strings.TrimSpace(candidate) != scope { + if canonicalConsentScopeName(candidate) != scope { continue } seen[scope] = struct{}{} @@ -481,10 +481,10 @@ func normalizeScopesInConsentOrder(scopes []string) []string { } appendIfPresent("openid") - appendIfPresent("tenant") + appendIfPresent("tenants") for _, scope := range combined { - scope = strings.TrimSpace(scope) + scope = canonicalConsentScopeName(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } @@ -501,7 +501,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { func requiredClientScopes(client domain.HydraClient) []string { required := make([]string, 0, 4) if clientTenantAccessRestricted(client.Metadata) { - required = append(required, "tenant") + required = append(required, "tenants") } if client.Metadata == nil { @@ -535,3 +535,12 @@ func requiredClientScopes(client domain.HydraClient) []string { return normalizeScopesInConsentOrder(required) } + +func canonicalConsentScopeName(scope string) string { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "tenant": + return "tenants" + default: + return strings.TrimSpace(scope) + } +} diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index 661cd631..af7ebf42 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -121,20 +121,20 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) { assert.False(t, hydraCalled) } -func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) { +func TestMergeRequestedScopesWithClientRequirements_AddsTenantsScope(t *testing.T) { client := domain.HydraClient{ Metadata: map[string]any{ "tenant_access_restricted": true, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, } merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"}) - assert.Equal(t, []string{"openid", "tenant", "profile"}, merged) + assert.Equal(t, []string{"openid", "tenants", "profile"}, merged) } func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) { @@ -154,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias []string{"openid", "offline", "profile", "offline_access"}, ) - assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged) + assert.Equal(t, []string{"openid", "tenants", "profile", "offline_access", "email"}, merged) } func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index fd67469a..a769b41d 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -864,7 +864,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, - "scope": "openid profile email tenant", + "scope": "openid profile email tenants", "token_endpoint_auth_method": "private_key_jwt", "metadata": map[string]any{ "status": "active", @@ -905,7 +905,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { body, _ := json.Marshal(map[string]any{ "name": "App One Updated", - "scopes": []string{"openid", "profile", "email", "tenant"}, + "scopes": []string{"openid", "profile", "email", "tenants"}, "metadata": map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-1", "tenant-2"}, @@ -3009,7 +3009,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing. RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, - Scope: "openid tenant profile email", + Scope: "openid tenants profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, @@ -3093,7 +3093,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing. RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, - Scope: "openid tenant profile email", + Scope: "openid tenants profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, diff --git a/common/core/i18n/loader.ts b/common/core/i18n/loader.ts index 7396445b..1ae75121 100644 --- a/common/core/i18n/loader.ts +++ b/common/core/i18n/loader.ts @@ -29,6 +29,23 @@ function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject { return result; } +function setTomlValue( + target: TomlObject, + path: string[], + value: TomlValue, +): void { + let cursor: TomlObject = target; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]; + const current = cursor[key]; + if (!current || typeof current === "string") { + cursor[key] = {}; + } + cursor = cursor[key] as TomlObject; + } + cursor[path[path.length - 1]] = value; +} + function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } @@ -82,7 +99,7 @@ function parseToml(raw: string): TomlObject { cursor = cursor[section] as TomlObject; } - cursor[key] = value; + setTomlValue(cursor, key.split("."), value); } return root; diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 2fa975a5..c605560f 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -91,7 +91,7 @@ function makeClientDetail( if (includeTenantScope) { structuredScopes.push({ id: "2", - name: "tenant", + name: "tenants", description: "Tenant access", mandatory: tenantScopeMandatory, locked: tenantAccessRestricted, @@ -106,7 +106,7 @@ function makeClientDetail( status: "active", redirectUris: ["https://rp.example.com/callback"], scopes: includeTenantScope - ? ["openid", "tenant", "profile"] + ? ["openid", "tenants", "profile"] : ["openid", "profile"], tokenEndpointAuthMethod: "client_secret_basic", metadata: { @@ -334,7 +334,48 @@ describe("ClientGeneralPage RP claims", () => { ); }); - it("preserves tenant scope mandatory state when tenant access restriction is off", async () => { + it("clears saved RP claims when custom claims are disabled", async () => { + const { container } = await renderPage(); + + const claimToggle = Array.from( + container.querySelectorAll('[role="switch"]'), + ).find((button) => + (button.getAttribute("aria-label") ?? "").includes("커스텀 클레임 사용"), + ); + expect(claimToggle).toBeDefined(); + expect(claimToggle?.getAttribute("aria-checked")).toBe("true"); + + await act(async () => { + claimToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(claimToggle?.getAttribute("aria-checked")).toBe("false"); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button.textContent?.includes("저장") || + button.textContent?.includes("Save"), + ); + expect(saveButton).toBeDefined(); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(updateClientMock).toHaveBeenCalledWith( + "client-claims", + expect.objectContaining({ + metadata: expect.objectContaining({ + id_token_claims_enabled: false, + id_token_claims: [], + }), + }), + ); + }); + + it("preserves tenants scope mandatory state when tenant access restriction is off", async () => { fetchClientMock.mockResolvedValue( makeClientDetail("old_claim", { includeTenantScope: true, @@ -355,7 +396,7 @@ describe("ClientGeneralPage RP claims", () => { const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find( (row) => Array.from(row.querySelectorAll("input")).some( - (input) => (input as HTMLInputElement).value === "tenant", + (input) => (input as HTMLInputElement).value === "tenants", ), ); @@ -393,7 +434,7 @@ describe("ClientGeneralPage RP claims", () => { tenant_access_restricted: false, structured_scopes: expect.arrayContaining([ expect.objectContaining({ - name: "tenant", + name: "tenants", mandatory: false, locked: false, }), diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 3ab2cd64..51d65218 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -30,14 +30,6 @@ import { CopyButton } from "../../components/ui/copy-button"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../../components/ui/table"; import { Textarea } from "../../components/ui/textarea"; import { toast } from "../../components/ui/use-toast"; import type { @@ -64,6 +56,16 @@ import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; +import { + SettingsTable, + SettingsTableBody, + SettingsTableCell, + SettingsTableEmptyState, + SettingsTableHead, + SettingsTableHeader, + SettingsTableRow, + SettingsTableShell, +} from "./components/SettingsTable"; import { TenantAccessPicker } from "./components/TenantAccessPicker"; import { claimDateTimeValueToInputString, @@ -631,6 +633,7 @@ function ClientGeneralPage() { const [allowedTenantIds, setAllowedTenantIds] = useState([]); const [autoLoginSupported, setAutoLoginSupported] = useState(false); const [autoLoginUrl, setAutoLoginUrl] = useState(""); + const [idTokenClaimsEnabled, setIdTokenClaimsEnabled] = useState(false); // Public Key Registration States const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = @@ -645,25 +648,37 @@ function ClientGeneralPage() { { id: "1", name: "openid", - description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"), + description: t( + "msg.dev.clients.scopes.openid", + "OIDC 로그인에 필요한 기본 scope", + ), mandatory: true, }, { id: "2", - name: "tenant", - description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"), + name: "tenants", + description: t( + "msg.dev.clients.scopes.tenants", + "tenant_id, joined_tenants, tenants 상세 및 root/부모 테넌트 접근", + ), mandatory: false, }, { id: "3", name: "profile", - description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"), + description: t( + "msg.dev.clients.scopes.profile", + "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근", + ), mandatory: false, }, { id: "4", name: "email", - description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), + description: t( + "msg.dev.clients.scopes.email", + "top-level email과 profile.email", + ), mandatory: false, }, ]); @@ -675,14 +690,14 @@ function ClientGeneralPage() { ); const tenantScopeDescription = t( - "msg.dev.clients.scopes.tenant", + "msg.dev.clients.scopes.tenants", "소속 테넌트 정보 접근", ); const buildTenantScope = useCallback( (id: string): ScopeItem => ({ id, - name: "tenant", + name: "tenants", description: tenantScopeDescription, mandatory: true, locked: true, @@ -693,12 +708,15 @@ function ClientGeneralPage() { const normalizeScopesForTenantAccess = useCallback( (nextScopes: ScopeItem[], restricted: boolean): ScopeItem[] => { const normalized = nextScopes.map((scope) => { - if (scope.name.trim() !== "tenant") { + const scopeName = scope.name.trim(); + if (scopeName !== "tenants" && scopeName !== "tenant") { return scope; } + const canonicalName = "tenants"; if (restricted) { return { ...scope, + name: canonicalName, description: scope.description || tenantScopeDescription, mandatory: true, locked: true, @@ -706,6 +724,7 @@ function ClientGeneralPage() { } return { ...scope, + name: canonicalName, description: scope.description || tenantScopeDescription, locked: false, }; @@ -713,20 +732,24 @@ function ClientGeneralPage() { if ( restricted && - !normalized.some((scope) => scope.name.trim() === "tenant") + !normalized.some( + (scope) => + scope.name.trim() === "tenants" || scope.name.trim() === "tenant", + ) ) { - normalized.push(buildTenantScope(`tenant-${Date.now()}`)); + normalized.push(buildTenantScope(`tenants-${Date.now()}`)); } const openidScopes = normalized.filter( (scope) => scope.name.trim() === "openid", ); const tenantScopes = normalized.filter( - (scope) => scope.name.trim() === "tenant", + (scope) => + scope.name.trim() === "tenants" || scope.name.trim() === "tenant", ); const remainingScopes = normalized.filter((scope) => { const name = scope.name.trim(); - return name !== "openid" && name !== "tenant"; + return name !== "openid" && name !== "tenants" && name !== "tenant"; }); return [...openidScopes, ...tenantScopes, ...remainingScopes]; @@ -741,7 +764,7 @@ function ClientGeneralPage() { name: "openid", description: t( "msg.dev.clients.scopes.openid", - "OIDC 인증 필수 스코프", + "OIDC 로그인에 필요한 기본 scope", ), source: "standard", }, @@ -750,19 +773,22 @@ function ClientGeneralPage() { name: "profile", description: t( "msg.dev.clients.scopes.profile", - "기본 프로필 정보 접근", + "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근", ), source: "standard", }, { id: "standard-email", name: "email", - description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), + description: t( + "msg.dev.clients.scopes.email", + "top-level email과 profile.email", + ), source: "standard", }, { id: "standard-tenant", - name: "tenant", + name: "tenants", description: tenantScopeDescription, source: "standard", }, @@ -931,7 +957,13 @@ function ClientGeneralPage() { ), ); } - setIdTokenClaims(readIdTokenClaimsMetadata(metadata)); + const savedIdTokenClaims = readIdTokenClaimsMetadata(metadata); + setIdTokenClaims(savedIdTokenClaims); + setIdTokenClaimsEnabled( + typeof metadata.id_token_claims_enabled === "boolean" + ? metadata.id_token_claims_enabled + : savedIdTokenClaims.length > 0, + ); }, [data, normalizeScopesForTenantAccess]); const securityProfile: SecurityProfile = @@ -1064,6 +1096,7 @@ function ClientGeneralPage() { }; const addIdTokenClaim = () => { + setIdTokenClaimsEnabled(true); setIdTokenClaims((current) => [ ...current, createIdTokenClaimItem(`claim-${Date.now()}`), @@ -1228,48 +1261,50 @@ function ClientGeneralPage() { } const claimValidationErrors: string[] = []; - const seenClaimKeys = new Set(); - for (const claim of normalizedIdTokenClaimItems) { - if (!claim.key) { - claimValidationErrors.push( - t( - "msg.dev.clients.general.id_token_claims.key_required", - "Claim key를 입력해야 합니다.", - ), - ); - continue; - } + if (idTokenClaimsEnabled) { + const seenClaimKeys = new Set(); + for (const claim of normalizedIdTokenClaimItems) { + if (!claim.key) { + claimValidationErrors.push( + t( + "msg.dev.clients.general.id_token_claims.key_required", + "Claim key를 입력해야 합니다.", + ), + ); + continue; + } - const keySignature = `${claim.namespace}:${claim.key}`; - if (seenClaimKeys.has(keySignature)) { - claimValidationErrors.push( - t( - "msg.dev.clients.general.id_token_claims.duplicate_key", - "중복된 claim key가 있습니다: {{namespace}}.{{key}}", - { - namespace: t( - "ui.dev.clients.general.id_token_claims.namespace_rp_claims", - "rp_claims", - ), - key: claim.key, - }, - ), - ); - continue; - } - seenClaimKeys.add(keySignature); + const keySignature = `${claim.namespace}:${claim.key}`; + if (seenClaimKeys.has(keySignature)) { + claimValidationErrors.push( + t( + "msg.dev.clients.general.id_token_claims.duplicate_key", + "중복된 claim key가 있습니다: {{namespace}}.{{key}}", + { + namespace: t( + "ui.dev.clients.general.id_token_claims.namespace_rp_claims", + "rp_claims", + ), + key: claim.key, + }, + ), + ); + continue; + } + seenClaimKeys.add(keySignature); - const defaultValueError = claimDefaultValueValidationError(claim); - if (defaultValueError) { - claimValidationErrors.push(defaultValueError); + const defaultValueError = claimDefaultValueValidationError(claim); + if (defaultValueError) { + claimValidationErrors.push(defaultValueError); + } } } validationErrors.push(...claimValidationErrors); const hasValidationErrors = validationErrors.length > 0; - const idTokenClaimPreview = buildIdTokenClaimsPreview( - normalizedIdTokenClaimItems, - ); + const idTokenClaimPreview = idTokenClaimsEnabled + ? buildIdTokenClaimsPreview(normalizedIdTokenClaimItems) + : []; const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2); const tenantOptions: TenantSummary[] = tenantData?.items ?? []; const selectedAllowedTenants = allowedTenantIds @@ -1410,7 +1445,8 @@ function ClientGeneralPage() { auto_login_supported: autoLoginSupported, auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined, structured_scopes: normalizedScopes, - id_token_claims: normalizedIdTokenClaims, + id_token_claims_enabled: idTokenClaimsEnabled, + id_token_claims: idTokenClaimsEnabled ? normalizedIdTokenClaims : [], token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: headlessLoginEnabled @@ -1867,85 +1903,8 @@ function ClientGeneralPage() { - - -
-
- - {t("ui.dev.clients.general.auto_login.title", "자동 로그인")} - - - {t( - "msg.dev.clients.general.auto_login.subtitle", - "RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다.", - )} - -
-
-
-

- {autoLoginSupported - ? t("ui.common.enabled", "사용") - : t("ui.common.disabled", "사용 안 함")} -

-

- {t( - "ui.dev.clients.general.auto_login.supported", - "자동 로그인 지원", - )} -

-
- -
-
-
- -
- - setAutoLoginUrl(event.target.value)} - disabled={!autoLoginSupported} - aria-invalid={!hasValidAutoLoginUrl} - className={!hasValidAutoLoginUrl ? "border-destructive" : ""} - placeholder={t( - "ui.dev.clients.general.auto_login.url_placeholder", - "https://app.example.com/login?auto=1", - )} - /> -

- {t( - "msg.dev.clients.general.auto_login.help", - "이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다.", - )} -

- {!hasValidAutoLoginUrl ? ( -

- {t( - "msg.dev.clients.general.auto_login.invalid_url", - "자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", - )} -

- ) : null} -
-
-
- {/* 2. Scopes */} + {/* 3. Custom Claims */}
@@ -1960,7 +1919,7 @@ function ClientGeneralPage() {
+ ) : null} + +
+
+

+ {idTokenClaimsEnabled + ? t("ui.common.enabled", "사용") + : t("ui.common.disabled", "사용 안 함")} +

+

+ {t( + "ui.dev.clients.general.id_token_claims.enabled", + "커스텀 클레임 사용", + )} +

+
+ +
+ +
+ {idTokenClaimsEnabled ? ( + +
+
+ + + + + + {t( + "ui.dev.clients.general.id_token_claims.table.key", + "Claim Key", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.namespace", + "Namespace", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.value_type", + "Value Type", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.nullable", + "Nullable", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.read_user_allowed", + "User read", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.write_user_allowed", + "User write", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.default_value", + "Default Value", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.delete", + "Delete", + )} + + + + + {idTokenClaims.length > 0 ? ( + idTokenClaims.map((claim) => { + const defaultValueError = + claimDefaultValueValidationError(claim); + + return ( + + + + updateIdTokenClaim( + claim.id, + "key", + e.target.value, + ) + } + className="h-8 font-mono text-xs" + placeholder={t( + "ui.dev.clients.general.id_token_claims.key_placeholder", + "e.g. locale", + )} + disabled={isGeneralSettingsReadOnly} + /> + + + + {t( + "ui.dev.clients.general.id_token_claims.namespace_rp_claims", + "rp_claims", + )} + + + + + + +
+ + updateIdTokenClaim( + claim.id, + "nullable", + checked, + ) + } + aria-label={t( + "ui.dev.clients.general.id_token_claims.nullable_label", + "Nullable", + )} + disabled={isGeneralSettingsReadOnly} + /> +
+
+ +
+ + setIdTokenClaimPermissionAllowed( + claim.id, + "readPermission", + checked, + ) + } + aria-label={t( + "ui.dev.clients.general.id_token_claims.read_user_allowed_label", + "사용자 읽기 허용", + )} + disabled={isGeneralSettingsReadOnly} + /> +
+
+ +
+ + setIdTokenClaimPermissionAllowed( + claim.id, + "writePermission", + checked, + ) + } + aria-label={t( + "ui.dev.clients.general.id_token_claims.write_user_allowed_label", + "사용자 쓰기 허용", + )} + disabled={isGeneralSettingsReadOnly} + /> +
+
+ + {claim.valueType === "array" || + claim.valueType === "object" ? ( +