forked from baron/baron-sso
offline 스코프 제거, rp_claims 값 표준화
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user