forked from baron/baron-sso
devfront ID Token Claims 백엔드 반영
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user