1
0
forked from baron/baron-sso

offline 스코프 제거, rp_claims 값 표준화

This commit is contained in:
2026-06-11 14:50:26 +09:00
parent f60b15a17b
commit c495e9119b
26 changed files with 1034 additions and 300 deletions

View File

@@ -1202,7 +1202,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
if includeTenantDetails {
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
if namespaced, ok := traits[tenantID].(map[string]any); ok {
maps.Copy(claims, namespaced)
maps.Copy(claims, sanitizeTenantClaimMetadata(namespaced))
}
}
}
@@ -1213,11 +1213,11 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
for k, v := range traits {
if k == "metadata" || k == "global_custom_claims" || k == "global_custom_claim_types" || k == "global_custom_claim_permissions" {
if isReservedTenantTraitKey(k) {
continue
}
if m, ok := v.(map[string]any); ok {
allTenants[k] = m
allTenants[k] = sanitizeTenantClaimMetadata(m)
}
}
@@ -1272,7 +1272,7 @@ func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) m
if key == "" || value == nil {
continue
}
if key == "rp_claims" || key == "rp_profiles" {
if isReservedTopLevelCustomClaimKey(key) {
continue
}
if _, exists := baseClaims[key]; exists {
@@ -1322,6 +1322,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m
if !ok {
continue
}
tenantClaim = sanitizeTenantClaimMetadata(tenantClaim)
tenant, ancestors, inHanmacFamily := h.resolveHanmacFamilyTenantClaimAncestry(ctx, tenantKey)
if !inHanmacFamily || tenant == nil {
@@ -1613,9 +1614,12 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
}
for _, claim := range normalizedClaims {
if claim.Namespace == "rp_claims" && isReservedRPClaimKey(claim.Key) {
continue
}
if claim.Nullable && strings.TrimSpace(claim.Value) == "" {
if claim.Namespace == "rp_claims" {
rpClaims[claim.Key] = nil
rpClaims[claim.Key] = buildRPClaimPayload(nil, claim, nil)
continue
}
if _, exists := baseClaims[claim.Key]; !exists {
@@ -1631,7 +1635,7 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
}
if claim.Namespace == "rp_claims" {
rpClaims[claim.Key] = value
rpClaims[claim.Key] = buildRPClaimPayload(value, claim, nil)
continue
}
@@ -1677,6 +1681,9 @@ func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[s
}
for _, claim := range rpClaimDefinitions {
if isReservedRPClaimKey(claim.Key) {
continue
}
raw, ok := row.Metadata[claim.Key]
if !ok || raw == nil {
continue
@@ -1686,7 +1693,7 @@ func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[s
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
continue
}
rpClaims[claim.Key] = value
rpClaims[claim.Key] = buildRPClaimPayload(value, claim, row.Metadata[claim.Key+"_permissions"])
}
if len(rpClaims) > 0 {
@@ -1723,6 +1730,92 @@ func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim
return definitions
}
func isReservedTopLevelCustomClaimKey(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), "rp_")
}
func isReservedRPClaimKey(key string) bool {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "rp_") {
return true
}
switch key {
case "", "tenant_id", "tenants", "joined_tenants", "lead_tenants":
return true
default:
return false
}
}
func isReservedTenantTraitKey(key string) bool {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "rp_") {
return true
}
switch key {
case "metadata",
"global_custom_claims",
"global_custom_claim_types",
"global_custom_claim_permissions":
return true
default:
return false
}
}
func isRPClaimRelatedTenantMetadataKey(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), "rp_")
}
func sanitizeTenantClaimMetadata(raw map[string]any) map[string]any {
cleaned := make(map[string]any, len(raw))
for key, value := range raw {
if isRPClaimRelatedTenantMetadataKey(key) {
continue
}
cleaned[key] = sanitizeTenantClaimValue(value)
}
return cleaned
}
func sanitizeTenantClaimValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return sanitizeTenantClaimMetadata(typed)
case []any:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, sanitizeTenantClaimValue(item))
}
return items
default:
return value
}
}
func buildRPClaimPayload(value any, claim normalizedIDTokenClaim, rawPermission any) map[string]any {
readPermission := normalizeCustomClaimPermission(claim.ReadPermission)
writePermission := normalizeCustomClaimPermission(claim.WritePermission)
if permissions, ok := rawPermission.(map[string]any); ok {
if rawRead := readInterfaceString(permissions["readPermission"], ""); rawRead != "" {
readPermission = normalizeCustomClaimPermission(rawRead)
}
if rawWrite := readInterfaceString(permissions["writePermission"], ""); rawWrite != "" {
writePermission = normalizeCustomClaimPermission(rawWrite)
}
}
if writePermission == "user_and_admin" {
readPermission = "user_and_admin"
}
return map[string]any{
"value": value,
"readPermission": readPermission,
"writePermission": writePermission,
}
}
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
switch value := raw.(type) {
case string:
@@ -1752,6 +1845,12 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
return value, true
}
case float64:
if valueType == "date" || valueType == "datetime" {
if value == math.Trunc(value) {
return value, true
}
return nil, false
}
if valueType == "float" {
return value, true
}
@@ -1760,6 +1859,12 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
}
case float32:
floatValue := float64(value)
if valueType == "date" || valueType == "datetime" {
if floatValue == math.Trunc(floatValue) {
return floatValue, true
}
return nil, false
}
if valueType == "float" {
return floatValue, true
}
@@ -1767,6 +1872,9 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
return floatValue, true
}
case int:
if valueType == "date" || valueType == "datetime" {
return float64(value), true
}
if valueType == "number" {
return float64(value), true
}
@@ -1774,6 +1882,9 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
return float64(value), true
}
case int64:
if valueType == "date" || valueType == "datetime" {
return float64(value), true
}
if valueType == "number" {
return float64(value), true
}
@@ -1781,6 +1892,10 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
return float64(value), true
}
case json.Number:
if valueType == "date" || valueType == "datetime" {
parsed, err := value.Int64()
return float64(parsed), err == nil
}
if valueType == "number" {
parsed, err := value.Int64()
return float64(parsed), err == nil
@@ -1795,120 +1910,6 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
return parsed, err == nil
}
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{}
}
if h == nil || h.RPUserMetadataRepo == nil {
return claims
}
clientID := strings.TrimSpace(client.ClientID)
subject = strings.TrimSpace(subject)
if clientID == "" || subject == "" {
return claims
}
claimKeys := extractClaimEnabledCustomUserSchemaKeys(client.Metadata)
if len(claimKeys) == 0 {
return claims
}
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
if err != nil || row == nil || len(row.Metadata) == 0 {
return claims
}
fields := make(map[string]any)
for _, key := range claimKeys {
raw, ok := row.Metadata[key]
if !ok || raw == nil {
continue
}
if value, ok := raw.(string); ok {
value = strings.TrimSpace(value)
if value == "" {
continue
}
fields[key] = value
continue
}
fields[key] = raw
}
if len(fields) == 0 {
return claims
}
profile := map[string]any{
"client_id": clientID,
"fields": fields,
}
if existing, ok := claims["rp_profiles"].([]any); ok {
claims["rp_profiles"] = append(existing, profile)
return claims
}
if existing, ok := claims["rp_profiles"].([]any); ok {
claims["rp_profiles"] = append(existing, profile)
return claims
}
claims["rp_profiles"] = []any{profile}
return claims
}
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]any) []string {
if metadata == nil {
return nil
}
rawSchema, ok := metadata["customUserSchema"]
if !ok || rawSchema == nil {
return nil
}
var items []any
switch schema := rawSchema.(type) {
case []any:
items = schema
case []map[string]any:
items = make([]any, 0, len(schema))
for _, item := range schema {
items = append(items, item)
}
default:
return nil
}
keys := make([]string, 0, len(items))
seen := make(map[string]struct{})
for _, item := range items {
field, ok := item.(map[string]any)
if !ok {
if typed, typedOK := item.(map[string]any); typedOK {
field = typed
} else {
continue
}
}
enabled, _ := field["claimEnabled"].(bool)
if !enabled {
enabled, _ = field["claim_enabled"].(bool)
}
if !enabled {
continue
}
key, _ := field["key"].(string)
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
return keys
}
func collectEmailList(traits map[string]any, primaryEmail string) []string {
emails := make([]string, 0)
seen := make(map[string]struct{})
@@ -6196,7 +6197,6 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil {
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
@@ -6235,7 +6235,6 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
@@ -6427,7 +6426,6 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
@@ -8432,7 +8430,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" {
if scope == "" || isRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -511,7 +512,7 @@ func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *tes
assert.NotContains(t, claims, "lead_tenants")
}
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -579,7 +580,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
"approvalLevel": "A",
"internalMemo": "관리자 전용",
},
}, nil).Once()
}, nil).Maybe()
h.RPUserMetadataRepo = repo
app := fiber.New()
@@ -597,14 +598,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
assert.True(t, ok)
assert.Len(t, rpProfiles, 1)
profile := rpProfiles[0].(map[string]any)
assert.Equal(t, "client-app", profile["client_id"])
fields := profile["fields"].(map[string]any)
assert.Equal(t, "A", fields["approvalLevel"])
assert.NotContains(t, fields, "internalMemo")
assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest())
repo.AssertExpectations(t)
}
@@ -728,7 +722,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-configured-claims",
"requested_scope": []string{"openid", "profile"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-789",
"client": map[string]any{
"client_id": "client-configured-claims",
@@ -823,8 +817,13 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, float64(2), rpClaims["tier"])
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
tier := rpClaims["tier"].(map[string]any)
assert.Equal(t, float64(2), tier["value"])
assert.Equal(t, "admin_only", tier["readPermission"])
assert.Equal(t, "admin_only", tier["writePermission"])
features := rpClaims["features"].(map[string]any)
assert.Equal(t, []any{"sso", "claims"}, features["value"])
}
}
@@ -835,7 +834,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-rp-user-claims",
"requested_scope": []string{"openid", "profile"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-rp-claims",
"client": map[string]any{
"client_id": "client-rp-claims",
@@ -883,6 +882,14 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
"value": "2026-06-09T09:30",
"valueType": "datetime",
},
{
"namespace": "rp_claims",
"key": "tenants",
"value": "must-not-shadow-tenants",
"valueType": "text",
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
},
},
},
},
@@ -914,10 +921,36 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{
ID: "user-rp-claims",
Traits: map[string]any{
"email": "rp-user@example.com",
"name": "RP User",
"email": "rp-user@example.com",
"name": "RP User",
"tenant_id": "tenant-leaf",
"tenant-leaf": map[string]any{
"department": "Platform",
"rp_claims": map[string]any{"mustNotLeak": true},
"rp_custom_claims": map[string]any{"client-rp-claims": map[string]any{"mustNotLeak": true}},
},
"rp_custom_claims": map[string]any{
"client-rp-claims": map[string]any{"approvalLevel": "B"},
},
},
}, nil)
rootTenantID := "tenant-root"
mockTenantSvc := new(MockTenantService)
mockTenantSvc.On("GetTenant", mock.Anything, "tenant-leaf").Return(&domain.Tenant{
ID: "tenant-leaf",
Slug: "platform",
Name: "플랫폼팀",
Type: domain.TenantTypeUserGroup,
ParentID: &rootTenantID,
}, nil)
mockTenantSvc.On("GetTenant", mock.Anything, rootTenantID).Return(&domain.Tenant{
ID: rootTenantID,
Slug: "root",
Name: "루트",
Type: domain.TenantTypeCompany,
}, nil)
mockTenantSvc.On("ListJoinedTenants", mock.Anything, "user-rp-claims").Return([]domain.Tenant{}, nil)
h.TenantService = mockTenantSvc
repo := new(devMockRPUserMetadataRepo)
repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{
ClientID: "client-rp-claims",
@@ -931,8 +964,8 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"contractDate": float64(1781017200),
"approvedAt": float64(1780968600),
"internalMemo": "must-not-leak",
"approvalLevel_permissions": map[string]any{
"readPermission": "admin_only",
@@ -947,7 +980,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-user-claims",
"grant_scope": []string{"openid", "profile"},
"grant_scope": []string{"openid", "profile", "tenant"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -959,16 +992,58 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
assert.NotNil(t, capturedClaims)
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "B", rpClaims["approvalLevel"])
assert.Equal(t, false, rpClaims["activeMember"])
assert.Equal(t, float64(42), rpClaims["score"])
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
approvalLevel := rpClaims["approvalLevel"].(map[string]any)
assert.Equal(t, "B", approvalLevel["value"])
assert.Equal(t, "user_and_admin", approvalLevel["readPermission"])
assert.Equal(t, "user_and_admin", approvalLevel["writePermission"])
activeMember := rpClaims["activeMember"].(map[string]any)
assert.Equal(t, false, activeMember["value"])
score := rpClaims["score"].(map[string]any)
assert.Equal(t, float64(42), score["value"])
featureList := rpClaims["featureList"].(map[string]any)
assert.Equal(t, []any{"sso", "claims"}, featureList["value"])
preferences := rpClaims["preferences"].(map[string]any)
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, preferences["value"])
contractDate := rpClaims["contractDate"].(map[string]any)
assert.Equal(t, float64(1781017200), contractDate["value"])
approvedAt := rpClaims["approvedAt"].(map[string]any)
assert.Equal(t, float64(1780968600), approvedAt["value"])
assert.NotContains(t, rpClaims, "tenants")
assert.NotContains(t, rpClaims, "internalMemo")
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
}
assert.NotContains(t, capturedClaims, "rp_profiles")
assert.NotContains(t, capturedClaims["joined_tenants"], "rp_custom_claims")
tenants := capturedClaims["tenants"].(map[string]any)
assert.Contains(t, tenants, "tenant-leaf")
assert.NotEqual(t, "must-not-shadow-tenants", capturedClaims["tenants"])
assertNoRPClaimDataInTenantClaims(t, tenants)
assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest())
repo.AssertExpectations(t)
}
func legacyProfileArrayClaimKeyForTest() string {
return strings.Join([]string{"rp", "profiles"}, "_")
}
func assertNoRPClaimDataInTenantClaims(t *testing.T, value any) {
t.Helper()
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
assert.False(t, strings.HasPrefix(key, "rp_"))
assertNoRPClaimDataInTenantClaims(t, child)
}
case []any:
for _, child := range typed {
assertNoRPClaimDataInTenantClaims(t, child)
}
}
}

View File

@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
appendIfPresent := func(scope string) {
scope = strings.TrimSpace(scope)
if scope == "" {
if scope == "" || isRefreshTokenScopeAlias(scope) {
return
}
if _, ok := seen[scope]; ok {
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
for _, scope := range combined {
scope = strings.TrimSpace(scope)
if scope == "" {
if scope == "" || isRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gofiber/fiber/v2"
@@ -135,6 +136,42 @@ func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
}
func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) {
client := domain.HydraClient{
Metadata: map[string]any{
"tenant_access_restricted": true,
"structured_scopes": []map[string]any{
{"name": "offline", "mandatory": true},
{"name": "offline_access", "locked": true},
{"name": "email", "mandatory": true},
},
},
}
merged := mergeRequestedScopesWithClientRequirements(
client,
[]string{"openid", "offline", "profile", "offline_access"},
)
assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged)
}
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
urlString := buildHydraAuthorizationURL(
"client-refresh",
[]string{"offline", "profile", "offline_access", "email"},
[]string{"https://rp.example.com/callback"},
)
parsed, err := url.Parse(urlString)
assert.NoError(t, err)
scopes := parsed.Query().Get("scope")
assert.Equal(t, "openid profile email", scopes)
assert.NotContains(t, scopes, "offline")
assert.NotContains(t, scopes, "offline_access")
}
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {

View File

@@ -15,6 +15,7 @@ import (
"io"
"log/slog"
"maps"
"math"
"net"
"net/http"
"net/url"
@@ -2099,7 +2100,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required")
}
scopes := derefSlice(req.Scopes, defaultClientScopes())
scopes := normalizeClientScopes(derefSlice(req.Scopes, defaultClientScopes()))
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
@@ -2186,7 +2187,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
Scope: buildScope(scopes),
TokenEndpointAuthMethod: tokenAuthMethod,
SkipConsent: new(valueOrBool(req.SkipConsent, true)),
JWKSUri: jwksURI,
@@ -3593,7 +3594,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
}
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
value := strings.TrimSpace(readClaimValueString(record["value"], ""))
nullable, _ := record["nullable"].(bool)
if !(nullable && value == "") {
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
@@ -3631,6 +3632,35 @@ func readInterfaceString(value any, fallback string) string {
return fallback
}
func readClaimValueString(value any, fallback string) string {
if value == nil {
return fallback
}
switch typed := value.(type) {
case string:
return typed
case float64:
if typed == math.Trunc(typed) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
floatValue := float64(typed)
if floatValue == math.Trunc(floatValue) {
return strconv.FormatInt(int64(floatValue), 10)
}
return strconv.FormatFloat(floatValue, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case json.Number:
return typed.String()
default:
return fallback
}
}
func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
trimmed := strings.TrimSpace(rawValue)
@@ -3703,21 +3733,36 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
if trimmed == "" {
return nil, errors.New("date value is required")
}
if _, err := time.Parse("2006-01-02", trimmed); err != nil {
return nil, errors.New("date value must use YYYY-MM-DD")
if isIntegerClaimLiteral(trimmed) {
parsed, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return nil, errors.New("date value must use unix seconds or YYYY-MM-DD")
}
return parsed, nil
}
return trimmed, nil
parsed, err := time.Parse("2006-01-02", trimmed)
if err != nil {
return nil, errors.New("date value must use unix seconds or YYYY-MM-DD")
}
return parsed.Unix(), nil
case "datetime":
if trimmed == "" {
return nil, errors.New("datetime value is required")
}
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
return trimmed, nil
if isIntegerClaimLiteral(trimmed) {
parsed, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return nil, errors.New("datetime value must use unix seconds, RFC3339, or YYYY-MM-DDTHH:mm")
}
return parsed, nil
}
if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil {
return trimmed, nil
if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil {
return parsed.Unix(), nil
}
return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm")
if parsed, err := time.ParseInLocation("2006-01-02T15:04", trimmed, time.UTC); err == nil {
return parsed.Unix(), nil
}
return nil, errors.New("datetime value must use unix seconds, RFC3339, or YYYY-MM-DDTHH:mm")
default:
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
}
@@ -3795,7 +3840,33 @@ func defaultResponseTypes() []string {
}
func buildScope(scopes []string) string {
return strings.Join(scopes, " ")
return strings.Join(normalizeClientScopes(scopes), " ")
}
func normalizeClientScopes(scopes []string) []string {
normalized := make([]string, 0, len(scopes))
seen := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
return normalized
}
func isRefreshTokenScopeAlias(scope string) bool {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "offline", "offline_access":
return true
default:
return false
}
}
func valueOr(ptr *string, fallback string) string {

View File

@@ -100,8 +100,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
row.Metadata["score"] == float64(42) &&
assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) &&
assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) &&
row.Metadata["contractDate"] == "2026-06-10" &&
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
rpMetadataNumberEquals(row.Metadata["contractDate"], 1781017200) &&
rpMetadataNumberEquals(row.Metadata["approvedAt"], 1780968600) &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
@@ -138,8 +138,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"contractDate": float64(1781017200),
"approvedAt": float64(1780968600),
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},
@@ -332,6 +332,21 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
kratos.AssertExpectations(t)
}
func rpMetadataNumberEquals(value any, want int64) bool {
switch typed := value.(type) {
case int64:
return typed == want
case int:
return int64(typed) == want
case float64:
return typed == float64(want)
case float32:
return float64(typed) == float64(want)
default:
return false
}
}
func TestDevHandler_SelfUpdateRPUserMetadataHonorsWritePermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/client-1" {
@@ -549,3 +564,21 @@ func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
}
func TestNormalizeIDTokenClaimsMetadataAcceptsUnixDateDefaults(t *testing.T) {
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
"id_token_claims": []any{
map[string]any{
"namespace": "rp_claims",
"key": "contract_date",
"valueType": "date",
"value": float64(1781020800),
},
},
})
require.NoError(t, err)
claims := metadata["id_token_claims"].([]normalizedIDTokenClaim)
require.Len(t, claims, 1)
require.Equal(t, "1781020800", claims[0].Value)
}

View File

@@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -571,7 +572,7 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "none",
"metadata": map[string]any{"status": "active"},
}), nil
@@ -621,7 +622,7 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
@@ -638,7 +639,7 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
@@ -700,7 +701,7 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "none",
"metadata": map[string]any{
"status": "active",
@@ -768,7 +769,7 @@ func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
@@ -785,7 +786,7 @@ func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
@@ -2176,6 +2177,131 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
assert.False(t, hasRequestObjectAlg)
}
func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(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)
err = json.Unmarshal(body, &captured)
assert.NoError(t, err)
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,
"skip_consent": captured.SkipConsent,
"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": "Refresh Token App",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "offline", "profile", "offline_access", "email"},
})
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)
assert.Equal(t, "openid profile email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}
func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T) {
var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-refresh" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-refresh",
"client_name": "Refresh Token App",
"redirect_uris": []string{"https://rp.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid offline profile offline_access email",
"token_endpoint_auth_method": "none",
"metadata": map[string]any{"status": "active"},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-refresh" {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
err = json.Unmarshal(body, &captured)
assert.NoError(t, err)
return httpJSONAny(r, http.StatusOK, 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,
"skip_consent": captured.SkipConsent,
"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.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "Refresh Token App Updated",
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-refresh", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "openid profile email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}
func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
var captured domain.HydraClient

View File

@@ -95,8 +95,8 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("contractDate", "date", float64(1780930800), "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", float64(1780965000), "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
}),
clientB: rpClaimsE2EClient(clientB, []map[string]any{
@@ -188,13 +188,14 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
assert.Equal(t, "A", initialA["approvalLevel"])
assert.Equal(t, true, initialA["activeMember"])
assert.Equal(t, float64(1), initialA["score"])
assert.Equal(t, []any{"default"}, initialA["featureList"])
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
assert.Equal(t, "2026-06-09", initialA["contractDate"])
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
assert.Equal(t, "A", rpClaimValue(t, initialA, "approvalLevel"))
assert.Equal(t, "user_and_admin", rpClaimPermission(t, initialA, "approvalLevel", "readPermission"))
assert.Equal(t, true, rpClaimValue(t, initialA, "activeMember"))
assert.Equal(t, float64(1), rpClaimValue(t, initialA, "score"))
assert.Equal(t, []any{"default"}, rpClaimValue(t, initialA, "featureList"))
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, rpClaimValue(t, initialA, "preferences"))
assert.Equal(t, float64(1780930800), rpClaimValue(t, initialA, "contractDate"))
assert.Equal(t, float64(1780965000), rpClaimValue(t, initialA, "approvedAt"))
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
"approvalLevel": "B",
@@ -202,8 +203,8 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
"score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{"theme": "dark", "density": "compact"},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"contractDate": float64(1781017200),
"approvedAt": float64(1780968600),
"adminManagedNote": "admin-updated",
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
@@ -211,14 +212,14 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
})
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
assert.Equal(t, "B", updatedA["approvalLevel"])
assert.Equal(t, false, updatedA["activeMember"])
assert.Equal(t, float64(42), updatedA["score"])
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
assert.Equal(t, "B", rpClaimValue(t, updatedA, "approvalLevel"))
assert.Equal(t, false, rpClaimValue(t, updatedA, "activeMember"))
assert.Equal(t, float64(42), rpClaimValue(t, updatedA, "score"))
assert.Equal(t, []any{"sso", "claims"}, rpClaimValue(t, updatedA, "featureList"))
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaimValue(t, updatedA, "preferences"))
assert.Equal(t, float64(1781017200), rpClaimValue(t, updatedA, "contractDate"))
assert.Equal(t, float64(1780968600), rpClaimValue(t, updatedA, "approvedAt"))
assert.Equal(t, "admin-updated", rpClaimValue(t, updatedA, "adminManagedNote"))
assert.NotContains(t, updatedA, "approvalLevel_permissions")
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
@@ -237,12 +238,12 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
assert.Equal(t, "C", selfUpdatedA["approvalLevel"])
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
assert.Equal(t, "C", rpClaimValue(t, selfUpdatedA, "approvalLevel"))
assert.Equal(t, "admin-updated", rpClaimValue(t, selfUpdatedA, "adminManagedNote"))
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
assert.Equal(t, "B-default", defaultB["approvalLevel"])
assert.Equal(t, false, defaultB["activeMember"])
assert.Equal(t, "B-default", rpClaimValue(t, defaultB, "approvalLevel"))
assert.Equal(t, false, rpClaimValue(t, defaultB, "activeMember"))
assert.NotContains(t, defaultB, "score")
assert.NotContains(t, defaultB, "featureList")
assert.NotContains(t, defaultB, "adminManagedNote")
@@ -252,9 +253,9 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
"activeMember": true,
})
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
assert.Equal(t, "B-rp-only", updatedB["approvalLevel"])
assert.Equal(t, true, updatedB["activeMember"])
assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"])
assert.Equal(t, "B-rp-only", rpClaimValue(t, updatedB, "approvalLevel"))
assert.Equal(t, true, rpClaimValue(t, updatedB, "activeMember"))
assert.NotEqual(t, rpClaimValue(t, selfUpdatedA, "approvalLevel"), rpClaimValue(t, updatedB, "approvalLevel"))
assert.NotContains(t, updatedB, "score")
assert.NotContains(t, updatedB, "featureList")
@@ -276,7 +277,7 @@ func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any
}
}
func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any {
func rpClaimsE2EClaim(key string, valueType string, value any, readPermission string, writePermission string) map[string]any {
return map[string]any{
"namespace": "rp_claims",
"key": key,
@@ -307,6 +308,24 @@ func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[s
return rpClaims
}
func rpClaimValue(t *testing.T, claims map[string]any, key string) any {
t.Helper()
payload, ok := claims[key].(map[string]any)
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
return payload["value"]
}
func rpClaimPermission(t *testing.T, claims map[string]any, key string, permissionKey string) string {
t.Helper()
payload, ok := claims[key].(map[string]any)
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
value, ok := payload[permissionKey].(string)
require.Truef(t, ok, "rp_claims.%s.%s must be a string", key, permissionKey)
return value
}
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
t.Helper()

View File

@@ -115,7 +115,7 @@ func tenantNamespaceIDsFromTraits(traits map[string]any) []string {
}
ids := make([]string, 0)
for key, value := range traits {
if key == "" || key == "metadata" {
if key == "" || isReservedTenantTraitKey(key) {
continue
}
switch value.(type) {