forked from baron/baron-sso
devfront ID Token Claims 백엔드 반영
This commit is contained in:
@@ -11,6 +11,7 @@ const (
|
|||||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
MetadataHeadlessJWKS = "headless_jwks"
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||||
|
MetadataIDTokenClaims = "id_token_claims"
|
||||||
MetadataAutoLoginSupported = "auto_login_supported"
|
MetadataAutoLoginSupported = "auto_login_supported"
|
||||||
MetadataAutoLoginURL = "auto_login_url"
|
MetadataAutoLoginURL = "auto_login_url"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1158,6 +1158,60 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
|
|||||||
return claims
|
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 {
|
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
claims = map[string]any{}
|
claims = map[string]any{}
|
||||||
@@ -5362,8 +5416,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
tenantID = tid
|
tenantID = tid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
@@ -5392,8 +5449,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
@@ -5575,8 +5635,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
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, "Security", capturedClaims["department"])
|
||||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
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"`
|
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{}{
|
var protectedSystemClientIDs = map[string]struct{}{
|
||||||
"oathkeeper-introspect": {},
|
"oathkeeper-introspect": {},
|
||||||
}
|
}
|
||||||
@@ -1656,7 +1663,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
@@ -1852,7 +1859,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
@@ -2752,6 +2759,174 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
|
|||||||
return nil
|
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 {
|
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||||
if req.Jwks != nil {
|
if req.Jwks != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1449,6 +1449,144 @@ func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
|||||||
assert.False(t, hydraCalled)
|
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) {
|
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||||
var captured domain.HydraClient
|
var captured domain.HydraClient
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user