1
0
forked from baron/baron-sso

devfront ID Token Claims 백엔드 반영

This commit is contained in:
2026-04-29 13:45:23 +09:00
parent e484d8c100
commit 0844befb35
5 changed files with 492 additions and 8 deletions

View File

@@ -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"
)

View File

@@ -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)

View File

@@ -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"])
}
}

View File

@@ -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

View File

@@ -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