From 0844befb353cf36d835553bc95ec33e7bb19b753 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 29 Apr 2026 13:45:23 +0900 Subject: [PATCH] =?UTF-8?q?devfront=20ID=20Token=20Claims=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/hydra_models.go | 1 + backend/internal/handler/auth_handler.go | 75 +++++++- .../auth_handler_dynamic_claims_test.go | 107 +++++++++++ backend/internal/handler/dev_handler.go | 179 +++++++++++++++++- backend/internal/handler/dev_handler_test.go | 138 ++++++++++++++ 5 files changed, 492 insertions(+), 8 deletions(-) diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index bcc4bd3e..f3bed2e3 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -11,6 +11,7 @@ const ( MetadataHeadlessJWKSURI = "headless_jwks_uri" MetadataHeadlessJWKS = "headless_jwks" MetadataRequestObjectSigningAlg = "request_object_signing_alg" + MetadataIDTokenClaims = "id_token_claims" MetadataAutoLoginSupported = "auto_login_supported" MetadataAutoLoginURL = "auto_login_url" ) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d99f5878..3c83f230 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1158,6 +1158,60 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string 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) + claims = applyConfiguredIDTokenClaims(claims, client.Metadata) + return withOidcSessionMetadata(claims, sessionID) +} + +func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any { + if baseClaims == nil { + baseClaims = map[string]any{} + } + if metadata == nil { + return baseClaims + } + + rawClaims, ok := metadata[domain.MetadataIDTokenClaims] + if !ok || rawClaims == nil { + return baseClaims + } + + normalizedClaims, err := normalizeIDTokenClaims(rawClaims) + if err != nil { + slog.Warn("failed to normalize configured id token claims", "error", err) + return baseClaims + } + + rpClaims, _ := baseClaims["rp_claims"].(map[string]any) + if rpClaims == nil { + rpClaims = map[string]any{} + } + + for _, claim := range normalizedClaims { + value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType) + if err != nil { + slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err) + continue + } + + if claim.Namespace == "rp_claims" { + rpClaims[claim.Key] = value + continue + } + + if _, exists := baseClaims[claim.Key]; exists { + continue + } + baseClaims[claim.Key] = value + } + + if len(rpClaims) > 0 { + baseClaims["rp_claims"] = rpClaims + } + return baseClaims +} + func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any { if claims == nil { claims = map[string]any{} @@ -5362,8 +5416,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { tenantID = tid } } - sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), + sessionClaims := composeOIDCSessionClaims( + consentRequest.Client, + identity.Traits, + consentRequest.RequestedScope, + tenantID, currentSessionID, ) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -5392,8 +5449,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { } } - sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), + sessionClaims := composeOIDCSessionClaims( + consentRequest.Client, + identity.Traits, + consentRequest.RequestedScope, + tenantID, currentSessionID, ) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -5575,8 +5635,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { } } - sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), + sessionClaims := composeOIDCSessionClaims( + consentRequest.Client, + identity.Traits, + consentRequest.RequestedScope, + tenantID, currentSessionID, ) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index d1e66e31..41e3f749 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -363,3 +363,110 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { assert.Equal(t, "Security", capturedClaims["department"]) assert.Equal(t, "Officer", capturedClaims["position"]) } + +func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { + var capturedClaims map[string]interface{} + + 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]interface{}{ + "challenge": "challenge-configured-claims", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-789", + "client": map[string]interface{}{ + "client_id": "client-configured-claims", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-claims", + "id_token_claims": []map[string]interface{}{ + { + "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]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-789").Return(&service.KratosIdentity{ + ID: "user-789", + Traits: map[string]interface{}{ + "email": "real-user@example.com", + "name": "Configured User", + "tenant-claims": map[string]interface{}{ + "department": "Platform", + }, + }, + }, nil) + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "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]interface{}) + if assert.True(t, ok) { + assert.Equal(t, float64(2), rpClaims["tier"]) + assert.Equal(t, []interface{}{"sso", "claims"}, rpClaims["features"]) + } +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2b2dd777..348e0aa1 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -194,6 +194,13 @@ type clientUpsertRequest struct { Metadata *map[string]interface{} `json:"metadata"` } +type normalizedIDTokenClaim struct { + Namespace string `json:"namespace"` + Key string `json:"key"` + Value string `json:"value"` + ValueType string `json:"valueType"` +} + var protectedSystemClientIDs = map[string]struct{}{ "oathkeeper-introspect": {}, } @@ -1656,7 +1663,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } - metadata, err = normalizeClientAutoLoginMetadata(metadata) + metadata, err = normalizeIDTokenClaimsMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } @@ -1852,7 +1859,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } - metadata, err = normalizeClientAutoLoginMetadata(metadata) + metadata, err = normalizeIDTokenClaimsMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } @@ -2752,6 +2759,174 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa return nil } +func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { + if metadata == nil { + return nil, nil + } + + rawClaims, exists := metadata[domain.MetadataIDTokenClaims] + if !exists || rawClaims == nil { + return metadata, nil + } + + normalized, err := normalizeIDTokenClaims(rawClaims) + if err != nil { + return nil, err + } + metadata[domain.MetadataIDTokenClaims] = normalized + return metadata, nil +} + +func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) { + rawList, ok := rawClaims.([]interface{}) + if !ok { + if typedList, ok := rawClaims.([]map[string]interface{}); ok { + rawList = make([]interface{}, 0, len(typedList)) + for _, item := range typedList { + rawList = append(rawList, item) + } + } else if typedList, ok := rawClaims.([]map[string]any); ok { + rawList = make([]interface{}, 0, len(typedList)) + for _, item := range typedList { + rawList = append(rawList, item) + } + } else { + return nil, errors.New("metadata.id_token_claims must be an array") + } + } + + normalized := make([]normalizedIDTokenClaim, 0, len(rawList)) + seen := make(map[string]struct{}, len(rawList)) + + for _, item := range rawList { + record, ok := item.(map[string]interface{}) + if !ok { + if typedRecord, ok := item.(map[string]any); ok { + record = make(map[string]interface{}, len(typedRecord)) + for key, value := range typedRecord { + record[key] = value + } + } else { + return nil, errors.New("metadata.id_token_claims items must be objects") + } + } + + namespace := strings.TrimSpace(readInterfaceString(record["namespace"], "top_level")) + if namespace == "" { + namespace = "top_level" + } + if namespace != "top_level" && namespace != "rp_claims" { + return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace) + } + + key := strings.TrimSpace(readInterfaceString(record["key"], "")) + if key == "" { + return nil, errors.New("metadata.id_token_claims key is required") + } + if namespace == "top_level" && key == "rp_claims" { + return nil, errors.New("metadata.id_token_claims top-level key rp_claims is reserved") + } + + valueType := strings.TrimSpace(readInterfaceString(record["valueType"], "text")) + if valueType == "" { + valueType = "text" + } + switch valueType { + case "text", "number", "boolean", "array", "object": + default: + return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType) + } + + value := strings.TrimSpace(readInterfaceString(record["value"], "")) + if _, err := parseConfiguredClaimValue(value, valueType); err != nil { + return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err) + } + + signature := namespace + ":" + key + if _, exists := seen[signature]; exists { + return nil, fmt.Errorf("metadata.id_token_claims contains duplicate key: %s.%s", namespace, key) + } + seen[signature] = struct{}{} + + normalized = append(normalized, normalizedIDTokenClaim{ + Namespace: namespace, + Key: key, + Value: value, + ValueType: valueType, + }) + } + + return normalized, nil +} + +func readInterfaceString(value interface{}, fallback string) string { + if value == nil { + return fallback + } + if text, ok := value.(string); ok { + return text + } + return fallback +} + +func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) { + trimmed := strings.TrimSpace(rawValue) + + switch valueType { + case "text": + return trimmed, nil + case "number": + if trimmed == "" { + return nil, errors.New("number value is required") + } + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return nil, errors.New("number value must be a finite number") + } + return parsed, nil + case "boolean": + switch strings.ToLower(trimmed) { + case "true", "1", "yes", "on": + return true, nil + case "false", "0", "no", "off": + return false, nil + default: + return nil, errors.New("boolean value must be true/false") + } + case "array": + if trimmed == "" { + return []string{}, nil + } + if strings.HasPrefix(trimmed, "[") { + var parsed []any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, errors.New("array value must be valid JSON array") + } + return parsed, nil + } + parts := strings.Split(trimmed, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + values = append(values, part) + } + } + return values, nil + case "object": + if trimmed == "" { + return map[string]any{}, nil + } + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, errors.New("object value must be valid JSON object") + } + return parsed, nil + default: + return nil, fmt.Errorf("unsupported claim value type: %s", valueType) + } +} + func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { if req.Jwks != nil { return true diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 1da8ab43..3c5b632f 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1449,6 +1449,144 @@ func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { assert.False(t, hydraCalled) } +func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.NoError(t, json.Unmarshal(body, &captured)) + + return httpJSONAny(r, http.StatusCreated, map[string]any{ + "client_id": captured.ClientID, + "client_name": captured.ClientName, + "redirect_uris": captured.RedirectURIs, + "grant_types": captured.GrantTypes, + "response_types": captured.ResponseTypes, + "scope": captured.Scope, + "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, + "metadata": captured.Metadata, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Claims App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + "metadata": map[string]any{ + "id_token_claims": []map[string]any{ + { + "id": "claim-1", + "namespace": "top_level", + "key": "locale", + "value": " ko-KR ", + "valueType": "text", + }, + { + "id": "claim-2", + "namespace": "rp_claims", + "key": "tier", + "value": "2", + "valueType": "number", + }, + }, + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{}) + if assert.True(t, ok) && assert.Len(t, claims, 2) { + first, ok := claims[0].(map[string]interface{}) + if assert.True(t, ok) { + assert.Equal(t, "top_level", first["namespace"]) + assert.Equal(t, "locale", first["key"]) + assert.Equal(t, "ko-KR", first["value"]) + assert.Equal(t, "text", first["valueType"]) + _, hasID := first["id"] + assert.False(t, hasID) + } + + second, ok := claims[1].(map[string]interface{}) + if assert.True(t, ok) { + assert.Equal(t, "rp_claims", second["namespace"]) + assert.Equal(t, "tier", second["key"]) + assert.Equal(t, "2", second["value"]) + assert.Equal(t, "number", second["valueType"]) + } + } +} + +func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) { + hydraCalled := false + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + hydraCalled = true + return httpJSONAny(r, http.StatusCreated, map[string]any{}), nil + })}, + }, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Claims App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + "metadata": map[string]any{ + "id_token_claims": []map[string]any{ + { + "namespace": "top_level", + "key": "rp_claims", + "value": "forbidden", + "valueType": "text", + }, + }, + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved") + assert.False(t, hydraCalled) +} + func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { var captured domain.HydraClient