diff --git a/internal/api/handlers/v0/auth/oidc.go b/internal/api/handlers/v0/auth/oidc.go index 0cef699b3..abf65f66e 100644 --- a/internal/api/handlers/v0/auth/oidc.go +++ b/internal/api/handlers/v0/auth/oidc.go @@ -210,29 +210,99 @@ func (h *OIDCHandler) validateExtraClaims(claims *OIDCClaims) error { return nil // No extra validation required } - // Parse extra claims configuration - var extraClaimsRules []map[string]any - if err := json.Unmarshal([]byte(h.config.OIDCExtraClaims), &extraClaimsRules); err != nil { - return fmt.Errorf("invalid extra claims configuration: %w", err) + extraClaimsRules, err := h.parseExtraClaimsRules() + if err != nil { + return fmt.Errorf("failed to parse extra claims rules: %w", err) } // Validate each rule for _, rule := range extraClaimsRules { - for key, expectedValue := range rule { - actualValue, exists := claims.ExtraClaims[key] - if !exists { - return fmt.Errorf("claim validation failed: required claim %s not found", key) - } + if err := h.validateSingleRule(claims, rule); err != nil { + return fmt.Errorf("claim validation failed for rule %v: %w", rule, err) + } + } - if actualValue != expectedValue { - return fmt.Errorf("claim validation failed: %s expected %v, got %v", key, expectedValue, actualValue) - } + return nil +} + +// parseExtraClaimsRules parses the extra claims configuration from JSON +func (h *OIDCHandler) parseExtraClaimsRules() ([]map[string]any, error) { + var extraClaimsRules []map[string]any + if err := json.Unmarshal([]byte(h.config.OIDCExtraClaims), &extraClaimsRules); err != nil { + return nil, fmt.Errorf("invalid extra claims configuration: %w", err) + } + return extraClaimsRules, nil +} + +// validateSingleRule validates a single claim rule against the OIDC claims +func (h *OIDCHandler) validateSingleRule(claims *OIDCClaims, rule map[string]any) error { + for key, expectedValue := range rule { + actualValue, exists := claims.ExtraClaims[key] + if !exists { + return fmt.Errorf("claim validation failed: required claim %s not found", key) + } + + if err := h.compareClaimValues(key, actualValue, expectedValue); err != nil { + return err } } + return nil +} + +// compareClaimValues compares actual and expected claim values with support for arrays +func (h *OIDCHandler) compareClaimValues(key string, actualValue, expectedValue any) error { + actualArray, actualIsArray := actualValue.([]any) + if actualIsArray { + return h.compareArrayClaimValues(key, actualArray, expectedValue) + } + + // Compare scalar values + if actualValue != expectedValue { + return fmt.Errorf("claim validation failed: %s expected %v, got %v", key, expectedValue, actualValue) + } return nil } +// compareArrayClaimValues handles comparison when actual value is an array +func (h *OIDCHandler) compareArrayClaimValues(key string, actualArray []any, expectedValue any) error { + expectedArray, expectedIsArray := expectedValue.([]any) + if expectedIsArray { + return h.compareArrayToArray(key, actualArray, expectedArray) + } + + return h.compareArrayToScalar(key, actualArray, expectedValue) +} + +// compareArrayToArray compares two arrays looking for any matching values +func (h *OIDCHandler) compareArrayToArray(key string, actualArray, expectedArray []any) error { + for _, actVal := range actualArray { + for _, expVal := range expectedArray { + if actVal == expVal { + return nil // Found a match + } + } + } + return fmt.Errorf("claim validation failed: %s no matching values found between actual %v and expected %v", key, actualArray, expectedArray) +} + +// compareArrayToScalar compares an array to a scalar value +func (h *OIDCHandler) compareArrayToScalar(key string, actualArray []any, expectedValue any) error { + if len(actualArray) == 1 { + // Normalize single-element arrays to scalars and compare + return h.compareClaimValues(key, actualArray[0], expectedValue) + } + + // Check if expected value exists in the array + for _, item := range actualArray { + if item == expectedValue { + return nil // Found a match + } + } + + return fmt.Errorf("claim validation failed: %s expected %v to be in array %v", key, expectedValue, actualArray) +} + // buildPermissions builds permissions based on OIDC claims and configuration func (h *OIDCHandler) buildPermissions(_ *OIDCClaims) []auth.Permission { var permissions []auth.Permission diff --git a/internal/api/handlers/v0/auth/oidc_test.go b/internal/api/handlers/v0/auth/oidc_test.go index 4ad8acb1d..24efde5a2 100644 --- a/internal/api/handlers/v0/auth/oidc_test.go +++ b/internal/api/handlers/v0/auth/oidc_test.go @@ -81,6 +81,186 @@ func TestOIDCHandler_ExchangeToken(t *testing.T) { token: "invalid-domain-token", expectedError: true, }, + { + name: "successful validation with extra claim 'client_id'", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://cigna.oktapreview.com", + OIDCClientID: "api://glbcore", + OIDCExtraClaims: `[{"client_id":"matched_client_id_value"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + Subject: "user-subject-123", + ExtraClaims: map[string]any{ + "email": "user@cigna.com", + "client_id": "matched_client_id_value", + }, + }, nil + }, + }, + token: "valid-okta-token", + expectedError: false, + }, + { + name: "failed validation with wrong extra claim 'client_id'", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://cigna.oktapreview.com", + OIDCClientID: "api://glbcore", + OIDCExtraClaims: `[{"client_id":"client_id_value"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + Subject: "user-subject-123", + ExtraClaims: map[string]any{ + "email": "user@cigna.com", + "client_id": "wrong_client_id_value", + }, + }, nil + }, + }, + token: "invalid-client-id-token", + expectedError: true, + }, + { + name: "successful validation with array claim - scalar expected value in array", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"groups":"admin"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "groups": []any{"admin", "users", "developers"}, + }, + }, nil + }, + }, + token: "valid-array-claim-token", + expectedError: false, + }, + { + name: "failed validation with array claim - scalar not in array", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"groups":"super-admin"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "groups": []any{"admin", "users", "developers"}, + }, + }, nil + }, + }, + token: "invalid-array-claim-token", + expectedError: true, + }, + { + name: "successful validation with array to array comparison - overlapping values", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"roles":["admin","moderator"]}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "roles": []any{"admin", "users"}, + }, + }, nil + }, + }, + token: "valid-array-array-token", + expectedError: false, + }, + { + name: "failed validation with array to array comparison - no overlapping values", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"roles":["super-admin","owner"]}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "roles": []any{"admin", "users"}, + }, + }, nil + }, + }, + token: "invalid-array-array-token", + expectedError: true, + }, + { + name: "successful validation with single-element array normalization", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"department":"engineering"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "department": []any{"engineering"}, // Single element array + }, + }, nil + }, + }, + token: "valid-single-array-token", + expectedError: false, + }, + { + name: "failed validation with missing claim", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"required_claim":"expected_value"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "other_claim": "some_value", + }, + }, nil + }, + }, + token: "missing-claim-token", + expectedError: true, + }, } for _, tt := range tests {