forked from baron/baron-sso
Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -1201,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1212,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,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 {
|
||||
@@ -1321,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 {
|
||||
@@ -1612,6 +1614,20 @@ 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] = buildRPClaimPayload(nil, claim, nil)
|
||||
continue
|
||||
}
|
||||
if _, exists := baseClaims[claim.Key]; !exists {
|
||||
baseClaims[claim.Key] = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)
|
||||
@@ -1619,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
|
||||
}
|
||||
|
||||
@@ -1635,7 +1651,7 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
}
|
||||
@@ -1649,8 +1665,8 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
|
||||
return claims
|
||||
}
|
||||
|
||||
claimKeys := extractClaimEnabledCustomUserSchemaKeys(client.Metadata)
|
||||
if len(claimKeys) == 0 {
|
||||
rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
|
||||
if len(rpClaimDefinitions) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
@@ -1659,94 +1675,239 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
|
||||
return claims
|
||||
}
|
||||
|
||||
fields := make(map[string]any)
|
||||
for _, key := range claimKeys {
|
||||
raw, ok := row.Metadata[key]
|
||||
rpClaims, _ := claims["rp_claims"].(map[string]any)
|
||||
if rpClaims == nil {
|
||||
rpClaims = map[string]any{}
|
||||
}
|
||||
|
||||
for _, claim := range rpClaimDefinitions {
|
||||
if isReservedRPClaimKey(claim.Key) {
|
||||
continue
|
||||
}
|
||||
raw, ok := row.Metadata[claim.Key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
if value, ok := raw.(string); ok {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
fields[key] = value
|
||||
value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType)
|
||||
if !ok {
|
||||
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
|
||||
continue
|
||||
}
|
||||
fields[key] = raw
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return claims
|
||||
rpClaims[claim.Key] = buildRPClaimPayload(value, claim, row.Metadata[claim.Key+"_permissions"])
|
||||
}
|
||||
|
||||
profile := map[string]any{
|
||||
"client_id": clientID,
|
||||
"fields": fields,
|
||||
if len(rpClaims) > 0 {
|
||||
claims["rp_claims"] = rpClaims
|
||||
}
|
||||
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 {
|
||||
func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
rawSchema, ok := metadata["customUserSchema"]
|
||||
if !ok || rawSchema == nil {
|
||||
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == 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)
|
||||
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
|
||||
if err != nil {
|
||||
slog.Warn("failed to normalize rp claim definitions", "error", err)
|
||||
return nil
|
||||
}
|
||||
definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims))
|
||||
seen := make(map[string]struct{}, len(normalizedClaims))
|
||||
for _, claim := range normalizedClaims {
|
||||
if claim.Namespace != "rp_claims" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[claim.Key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[claim.Key] = struct{}{}
|
||||
definitions = append(definitions, claim)
|
||||
}
|
||||
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 nil
|
||||
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"
|
||||
}
|
||||
|
||||
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 map[string]any{
|
||||
"value": value,
|
||||
"readPermission": readPermission,
|
||||
"writePermission": writePermission,
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := parseConfiguredClaimValue(value, valueType)
|
||||
return parsed, err == nil
|
||||
case []any:
|
||||
if valueType == "array" {
|
||||
return value, true
|
||||
}
|
||||
case []string:
|
||||
if valueType == "array" {
|
||||
items := make([]any, 0, len(value))
|
||||
for _, item := range value {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
case map[string]any:
|
||||
if valueType == "object" {
|
||||
return value, true
|
||||
}
|
||||
case bool:
|
||||
if valueType == "boolean" {
|
||||
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
|
||||
}
|
||||
if valueType == "number" && value == math.Trunc(value) {
|
||||
return value, true
|
||||
}
|
||||
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
|
||||
}
|
||||
if valueType == "number" && floatValue == math.Trunc(floatValue) {
|
||||
return floatValue, true
|
||||
}
|
||||
case int:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "float" {
|
||||
return float64(value), true
|
||||
}
|
||||
case int64:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "float" {
|
||||
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
|
||||
}
|
||||
if valueType == "float" {
|
||||
parsed, err := value.Float64()
|
||||
return parsed, err == nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
||||
@@ -6154,7 +6315,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPUserMetadataClaims(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 {
|
||||
@@ -6192,7 +6353,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
@@ -6383,7 +6544,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
@@ -8388,7 +8549,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 == "" || isLegacyRefreshTokenScopeAlias(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,7 +817,233 @@ 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"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
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", "tenant"},
|
||||
"subject": "user-rp-claims",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-rp-claims",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"value": "A",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "activeMember",
|
||||
"value": "true",
|
||||
"valueType": "boolean",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "score",
|
||||
"value": "1",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "featureList",
|
||||
"value": `["default"]`,
|
||||
"valueType": "array",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "preferences",
|
||||
"value": `{"theme":"light","density":"comfortable"}`,
|
||||
"valueType": "object",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contractDate",
|
||||
"value": "2026-06-09",
|
||||
"valueType": "date",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvedAt",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
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",
|
||||
"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",
|
||||
UserID: "user-rp-claims",
|
||||
Metadata: domain.JSONMap{
|
||||
"approvalLevel": "B",
|
||||
"activeMember": false,
|
||||
"score": float64(42),
|
||||
"featureList": []any{"sso", "claims"},
|
||||
"preferences": map[string]any{
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"internalMemo": "must-not-leak",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"readPermission": "admin_only",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
h.RPUserMetadataRepo = repo
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-rp-user-claims",
|
||||
"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")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
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["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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -50,35 +49,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server {
|
||||
func newKratosWhoamiTestServer(t *testing.T, identityID string) string {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
http.Error(w, "missing session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
origDefaultClient := http.DefaultClient
|
||||
http.DefaultClient = server.Client()
|
||||
http.DefaultClient = &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "missing session"), nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpResponse(r, http.StatusOK, string(body)), nil
|
||||
}),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefaultClient
|
||||
})
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
return "http://kratos.test"
|
||||
}
|
||||
|
||||
func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
||||
@@ -215,8 +216,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -248,8 +248,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -302,8 +301,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
||||
prefixLoginCodePending + "user@example.com": "pending-123",
|
||||
prefixLoginCodeValue + "pending-123": "569765",
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -393,8 +391,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -425,8 +422,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -456,18 +452,11 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
@@ -485,7 +474,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -497,6 +486,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
SmsService: &mockSmsService{},
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
@@ -529,10 +519,6 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -659,10 +645,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -748,8 +730,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a"))
|
||||
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
@@ -785,10 +766,6 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -880,8 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a"))
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
@@ -446,10 +445,6 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
headers map[string]string,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -463,11 +458,8 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -481,7 +473,7 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -494,8 +486,9 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -551,10 +544,6 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
logger *slog.Logger,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -568,11 +557,8 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -586,7 +572,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -599,8 +585,9 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -879,10 +866,6 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T
|
||||
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -891,11 +874,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -909,7 +889,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -924,8 +904,9 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -979,10 +960,6 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -991,11 +968,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
acceptCalled := false
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1012,7 +986,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1028,8 +1002,9 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee002").Return("kratos-target-b", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1065,10 +1040,6 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1077,11 +1048,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1097,7 +1065,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1112,8 +1080,9 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-userfront-a", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1271,10 +1240,6 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1283,11 +1248,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1301,7 +1263,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
"headless_jwks": map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
},
|
||||
@@ -1319,8 +1281,9 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1360,10 +1323,6 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1383,12 +1342,11 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
}
|
||||
|
||||
fetchCount := 0
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
jwksClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
fetchCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(freshRaw)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
return httpResponse(r, http.StatusOK, string(freshRaw)), nil
|
||||
})}
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1402,7 +1360,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1417,12 +1375,12 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
redisRepo := &testRedisRepo{values: map[string]string{}}
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client())
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksClient)
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(30 * time.Minute)
|
||||
if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "headless-login-client",
|
||||
JWKSURI: jwksServer.URL + "/.well-known/jwks.json",
|
||||
JWKSURI: jwksURI,
|
||||
RawJWKS: string(staleRaw),
|
||||
CachedKids: []string{"test-kid"},
|
||||
CachedAt: &now,
|
||||
@@ -1546,10 +1504,6 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1562,11 +1516,8 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
invalidKey, _ := mustHeadlessRSAJWK(t)
|
||||
_ = validKey
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1580,7 +1531,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1593,8 +1544,9 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -2198,8 +2150,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) {
|
||||
Subject: "kratos-user-1",
|
||||
}, nil)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil)
|
||||
@@ -2237,8 +2188,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) {
|
||||
Subject: "kratos-user-1",
|
||||
}, nil)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil)
|
||||
|
||||
@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
|
||||
appendIfPresent := func(scope string) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(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 == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -135,6 +137,43 @@ 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", "offline_access", "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")
|
||||
scopeItems := strings.Fields(scopes)
|
||||
|
||||
assert.Equal(t, "openid profile offline_access email", scopes)
|
||||
assert.NotContains(t, scopeItems, "offline")
|
||||
assert.Contains(t, scopeItems, "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"
|
||||
@@ -34,6 +35,7 @@ type DevHandler struct {
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
IdentityWriter service.IdentityWriteService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
@@ -49,9 +51,10 @@ type DevHandler struct {
|
||||
|
||||
type developerRequestService interface {
|
||||
RequestAccess(ctx context.Context, req domain.DeveloperRequest) error
|
||||
GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error)
|
||||
GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error)
|
||||
GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error)
|
||||
ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error)
|
||||
ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error)
|
||||
CreateGrant(ctx context.Context, req domain.DeveloperRequest) error
|
||||
ApproveRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
RejectRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
@@ -77,13 +80,18 @@ func NewDevHandler(
|
||||
authProvider = auth[0]
|
||||
}
|
||||
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
return &DevHandler{
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redis, nil),
|
||||
SecretRepo: secretRepo,
|
||||
AuditRepo: nil,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
KratosAdmin: kratosAdmin,
|
||||
IdentityWriter: service.NewIdentityWriteService(
|
||||
kratosAdmin,
|
||||
redis,
|
||||
),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: ketoOutbox,
|
||||
@@ -232,6 +240,7 @@ type normalizedIDTokenClaim struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
Nullable bool `json:"nullable"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
}
|
||||
@@ -274,6 +283,56 @@ func isDevConsoleViewerRole(role string) bool {
|
||||
return r == domain.RoleSuperAdmin || r == domain.RoleUser
|
||||
}
|
||||
|
||||
func normalizeDeveloperAccessPagesForHandler(pages []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
normalized := make([]string, 0, len(pages))
|
||||
add := func(page string) {
|
||||
page = strings.ToLower(strings.TrimSpace(page))
|
||||
if page == "" {
|
||||
return
|
||||
}
|
||||
if page == domain.DeveloperAccessPageAll {
|
||||
normalized = []string{domain.DeveloperAccessPageAll}
|
||||
seen = map[string]struct{}{domain.DeveloperAccessPageAll: {}}
|
||||
return
|
||||
}
|
||||
for _, allowed := range domain.DeveloperAccessPageOrder {
|
||||
if page == allowed {
|
||||
if _, exists := seen[page]; exists {
|
||||
return
|
||||
}
|
||||
seen[page] = struct{}{}
|
||||
normalized = append(normalized, page)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, page := range pages {
|
||||
add(page)
|
||||
if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []string{domain.DeveloperAccessPageAll}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func developerAccessPagesEqual(left, right []string) bool {
|
||||
leftNormalized := normalizeDeveloperAccessPagesForHandler(left)
|
||||
rightNormalized := normalizeDeveloperAccessPagesForHandler(right)
|
||||
if len(leftNormalized) != len(rightNormalized) {
|
||||
return false
|
||||
}
|
||||
for i := range leftNormalized {
|
||||
if leftNormalized[i] != rightNormalized[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
|
||||
if profile == nil {
|
||||
return
|
||||
@@ -455,9 +514,7 @@ func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.U
|
||||
if err != nil || status == nil {
|
||||
return false
|
||||
}
|
||||
return status.Status == domain.DeveloperRequestStatusApproved &&
|
||||
strings.TrimSpace(status.UserID) == userID &&
|
||||
strings.TrimSpace(status.TenantID) == tenantID
|
||||
return status.Status == domain.DeveloperRequestStatusApproved
|
||||
}
|
||||
|
||||
func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool {
|
||||
@@ -517,6 +574,30 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User
|
||||
return canAccessClientByLegacyScope(profile, summary)
|
||||
}
|
||||
|
||||
func (h *DevHandler) canManageRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
return h.canOperateClientByPermit(c, profile, summary, "manage")
|
||||
}
|
||||
|
||||
func (h *DevHandler) canSelfUpdateRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
if h.Keto == nil {
|
||||
return true
|
||||
}
|
||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "access")
|
||||
return err == nil && allowed
|
||||
}
|
||||
|
||||
func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
if profile == nil || h.Hydra == nil {
|
||||
@@ -1556,7 +1637,7 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
if !h.canManageClientRelations(c, profile, summary) {
|
||||
if !h.canManageRPUserMetadata(c, profile, summary) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update client metadata")
|
||||
}
|
||||
|
||||
@@ -1589,6 +1670,73 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
|
||||
return c.JSON(row)
|
||||
}
|
||||
|
||||
func (h *DevHandler) SelfUpdateRPUserMetadata(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
if h.RPUserMetadataRepo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable")
|
||||
}
|
||||
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil || strings.TrimSpace(profile.ID) == "" {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
|
||||
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
if !h.canSelfUpdateRPUserMetadata(c, profile, summary) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update own client metadata")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
if req.Metadata == nil {
|
||||
req.Metadata = map[string]any{}
|
||||
}
|
||||
|
||||
filteredMetadata, err := filterSelfWritableRPUserMetadata(req.Metadata, summary.Metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||
}
|
||||
normalizedMetadata, err := normalizeRPUserMetadataForClient(filteredMetadata, summary.Metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(profile.ID)
|
||||
mergedMetadata := domain.JSONMap{}
|
||||
if existing, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID); err == nil && existing != nil {
|
||||
for key, value := range existing.Metadata {
|
||||
mergedMetadata[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range normalizedMetadata {
|
||||
mergedMetadata[key] = value
|
||||
}
|
||||
|
||||
row := &domain.RPUserMetadata{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Metadata: mergedMetadata,
|
||||
}
|
||||
if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, mergedMetadata); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(row)
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil
|
||||
@@ -1610,7 +1758,17 @@ func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID stri
|
||||
}
|
||||
rawRPClaims[clientID] = metadata
|
||||
traits["rp_custom_claims"] = rawRPClaims
|
||||
_, err = h.KratosAdmin.UpdateIdentity(ctx, identity.ID, traits, identity.State)
|
||||
identityWriter := h.IdentityWriter
|
||||
if identityWriter == nil {
|
||||
identityWriter = service.NewIdentityWriteService(h.KratosAdmin, h.Redis)
|
||||
}
|
||||
_, err = identityWriter.UpdateIdentity(ctx, service.IdentityUpdateRequest{
|
||||
IdentityID: identity.ID,
|
||||
Traits: traits,
|
||||
State: identity.State,
|
||||
Reason: "rp_custom_claims_sync",
|
||||
Source: "dev_handler",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update kratos rp user metadata: %w", err)
|
||||
}
|
||||
@@ -1703,6 +1861,33 @@ func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata ma
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func filterSelfWritableRPUserMetadata(metadata map[string]any, clientMetadata map[string]any) (map[string]any, error) {
|
||||
schemas, err := rpUserMetadataClaimSchemas(clientMetadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := map[string]any{}
|
||||
for rawKey, rawValue := range metadata {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" || isEmptyRPUserMetadataValue(rawValue) {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(key, "_permissions") {
|
||||
return nil, fmt.Errorf("rp user metadata permission cannot be updated by user: %s", key)
|
||||
}
|
||||
schema, ok := schemas[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key)
|
||||
}
|
||||
if normalizeCustomClaimPermission(schema.WritePermission) != "user_and_admin" {
|
||||
return nil, fmt.Errorf("rp user metadata claim is admin only: %s", key)
|
||||
}
|
||||
filtered[key] = rawValue
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) {
|
||||
rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == nil {
|
||||
@@ -1915,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())
|
||||
|
||||
@@ -2002,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,
|
||||
@@ -3404,14 +3589,17 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
|
||||
valueType = "text"
|
||||
}
|
||||
switch valueType {
|
||||
case "text", "number", "boolean", "array", "object", "date", "datetime":
|
||||
case "text", "number", "float", "boolean", "array", "object", "date", "datetime":
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
|
||||
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
|
||||
value := strings.TrimSpace(readClaimValueString(record["value"], ""))
|
||||
nullable, _ := record["nullable"].(bool)
|
||||
if !(nullable && value == "") {
|
||||
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
signature := namespace + ":" + key
|
||||
@@ -3425,6 +3613,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
Nullable: nullable,
|
||||
ReadPermission: normalizeCustomClaimPermission(record["readPermission"]),
|
||||
WritePermission: normalizeCustomClaimPermission(record["writePermission"]),
|
||||
})
|
||||
@@ -3443,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)
|
||||
|
||||
@@ -3453,9 +3671,24 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("number value is required")
|
||||
}
|
||||
if !isIntegerClaimLiteral(trimmed) {
|
||||
return nil, errors.New("number value must be an integer")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("number value must be a finite number")
|
||||
return nil, errors.New("number value must be an integer")
|
||||
}
|
||||
return parsed, nil
|
||||
case "float":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("float value is required")
|
||||
}
|
||||
if !isFloatClaimLiteral(trimmed) {
|
||||
return nil, errors.New("float value must be a finite decimal number")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("float value must be a finite decimal number")
|
||||
}
|
||||
return parsed, nil
|
||||
case "boolean":
|
||||
@@ -3500,26 +3733,89 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func isIntegerClaimLiteral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
start := 0
|
||||
if value[0] == '-' {
|
||||
if len(value) == 1 {
|
||||
return false
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
for _, char := range value[start:] {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFloatClaimLiteral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
start := 0
|
||||
if value[0] == '-' {
|
||||
if len(value) == 1 {
|
||||
return false
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
hasDigit := false
|
||||
hasDot := false
|
||||
for _, char := range value[start:] {
|
||||
switch {
|
||||
case char >= '0' && char <= '9':
|
||||
hasDigit = true
|
||||
case char == '.':
|
||||
if hasDot {
|
||||
return false
|
||||
}
|
||||
hasDot = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||
if req.Jwks != nil {
|
||||
return true
|
||||
@@ -3544,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 == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isLegacyRefreshTokenScopeAlias(scope string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "offline":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func valueOr(ptr *string, fallback string) string {
|
||||
@@ -3805,6 +4127,85 @@ func (h *DevHandler) countScopedDashboardAuditMetrics(
|
||||
return failureCount, int64(len(activeSessions)), nil
|
||||
}
|
||||
|
||||
func appendDevTenantUnique(tenants []domain.Tenant, tenant domain.Tenant) []domain.Tenant {
|
||||
tenantID := strings.TrimSpace(tenant.ID)
|
||||
tenantSlug := strings.TrimSpace(tenant.Slug)
|
||||
if tenantID == "" && tenantSlug == "" {
|
||||
return tenants
|
||||
}
|
||||
|
||||
for _, existing := range tenants {
|
||||
if tenantID != "" && strings.EqualFold(existing.ID, tenantID) {
|
||||
return tenants
|
||||
}
|
||||
if tenantSlug != "" && strings.EqualFold(existing.Slug, tenantSlug) {
|
||||
return tenants
|
||||
}
|
||||
}
|
||||
|
||||
return append(tenants, tenant)
|
||||
}
|
||||
|
||||
func shouldListDevManageableTenants(role string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "tenant_admin", "tenantadmin", "tenant-admin", "rp_admin", "admin":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDevProfileAppointmentTenants(ctx context.Context, tenantSvc service.TenantService, metadata map[string]any) []domain.Tenant {
|
||||
if tenantSvc == nil || metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenants := make([]domain.Tenant, 0, len(appointments))
|
||||
for _, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if tenantID := normalizeMetadataString(appointment["tenantId"]); tenantID != "" {
|
||||
if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *tenant)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tenantID := normalizeMetadataString(appointment["tenant_id"]); tenantID != "" {
|
||||
if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *tenant)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tenantSlug := normalizeMetadataString(appointment["tenantSlug"]); tenantSlug != "" {
|
||||
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *tenant)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tenantSlug := normalizeMetadataString(appointment["tenant_slug"]); tenantSlug != "" {
|
||||
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *tenant)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tenantSlug := normalizeMetadataString(appointment["slug"]); tenantSlug != "" {
|
||||
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *tenant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tenants
|
||||
}
|
||||
|
||||
// ListMyTenants returns the list of tenants the current user manages or belongs to.
|
||||
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
profile, err := h.Auth.GetEnrichedProfile(c)
|
||||
@@ -3813,20 +4214,6 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if role == domain.RoleUser {
|
||||
if profile.TenantID == nil || strings.TrimSpace(*profile.TenantID) == "" {
|
||||
return c.JSON([]domain.Tenant{})
|
||||
}
|
||||
tenant, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
|
||||
}
|
||||
if tenant == nil {
|
||||
return c.JSON([]domain.Tenant{})
|
||||
}
|
||||
return c.JSON([]domain.Tenant{*tenant})
|
||||
}
|
||||
|
||||
if role == domain.RoleSuperAdmin {
|
||||
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "", "")
|
||||
if err != nil {
|
||||
@@ -3835,26 +4222,32 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
return c.JSON(tenants)
|
||||
}
|
||||
|
||||
tenants, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
|
||||
tenants := make([]domain.Tenant, 0, 1+len(profile.JoinedTenants))
|
||||
if shouldListDevManageableTenants(profile.Role) {
|
||||
manageable, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
|
||||
}
|
||||
for _, tenant := range manageable {
|
||||
tenants = appendDevTenantUnique(tenants, tenant)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.TenantID != nil && *profile.TenantID != "" {
|
||||
found := false
|
||||
for _, t := range tenants {
|
||||
if t.ID == *profile.TenantID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err == nil && primary != nil {
|
||||
tenants = append(tenants, *primary)
|
||||
}
|
||||
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
|
||||
} else if primary != nil {
|
||||
tenants = appendDevTenantUnique(tenants, *primary)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tenant := range profile.JoinedTenants {
|
||||
tenants = appendDevTenantUnique(tenants, tenant)
|
||||
}
|
||||
for _, tenant := range resolveDevProfileAppointmentTenants(c.Context(), h.TenantSvc, profile.Metadata) {
|
||||
tenants = appendDevTenantUnique(tenants, tenant)
|
||||
}
|
||||
|
||||
return c.JSON(tenants)
|
||||
}
|
||||
|
||||
@@ -3871,10 +4264,11 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Reason string `json:"reason"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Reason string `json:"reason"`
|
||||
TenantID string `json:"tenantId"`
|
||||
AccessPages []string `json:"accessPages"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -3883,16 +4277,16 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
if req.TenantID == "" && profile.TenantID != nil {
|
||||
req.TenantID = *profile.TenantID
|
||||
}
|
||||
if req.TenantID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
organization := strings.TrimSpace(req.Organization)
|
||||
if h.TenantSvc != nil {
|
||||
if organization == "" {
|
||||
organization = strings.TrimSpace(profile.CompanyCode)
|
||||
}
|
||||
if req.TenantID != "" && h.TenantSvc != nil {
|
||||
if tenant, err := h.TenantSvc.GetTenant(c.Context(), req.TenantID); err == nil && tenant != nil && strings.TrimSpace(tenant.Name) != "" {
|
||||
organization = strings.TrimSpace(tenant.Name)
|
||||
}
|
||||
@@ -3907,6 +4301,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
Phone: profile.Phone,
|
||||
Role: normalizeUserRole(profile.Role),
|
||||
Reason: req.Reason,
|
||||
AccessPages: req.AccessPages,
|
||||
Status: domain.DeveloperRequestStatusPending,
|
||||
}
|
||||
|
||||
@@ -3927,9 +4322,6 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = *profile.TenantID
|
||||
}
|
||||
if tenantID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID)
|
||||
if err != nil {
|
||||
@@ -3937,10 +4329,10 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if status == nil {
|
||||
return c.JSON(fiber.Map{"status": "none"})
|
||||
return c.JSON(domain.DeveloperAccessStatus{Status: "none"})
|
||||
}
|
||||
if status.Status == domain.DeveloperRequestStatusApproved {
|
||||
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
|
||||
h.ensureDeveloperGrantRelation(c, profile.ID, tenantID)
|
||||
}
|
||||
|
||||
return c.JSON(status)
|
||||
@@ -4049,7 +4441,7 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||
userID = ""
|
||||
}
|
||||
|
||||
requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status)
|
||||
requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
@@ -4057,6 +4449,169 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||
return c.JSON(requests)
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListDeveloperGrants(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
tenantID := strings.TrimSpace(c.Query("tenantId"))
|
||||
grants, err := h.DeveloperSvc.ListRequests(c.Context(), "", domain.DeveloperRequestStatusApproved, tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(grants)
|
||||
}
|
||||
|
||||
func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
UserID string `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Reason string `json:"reason"`
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
AccessPages []string `json:"accessPages"`
|
||||
}
|
||||
if err := c.BodyParser(&reqBody); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(reqBody.UserID)
|
||||
tenantID := strings.TrimSpace(reqBody.TenantID)
|
||||
if userID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "userId is required")
|
||||
}
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "required services are unavailable")
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil || identity == nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(extractTraitString(identity.Traits, "name"))
|
||||
if name == "" {
|
||||
name = userID
|
||||
}
|
||||
organization := strings.TrimSpace(extractTraitString(identity.Traits, "companyCode"))
|
||||
if tenantID != "" && h.TenantSvc != nil {
|
||||
tenant, err := h.TenantSvc.GetTenant(c.Context(), tenantID)
|
||||
if err != nil || tenant == nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
|
||||
}
|
||||
if strings.TrimSpace(tenant.Name) != "" {
|
||||
organization = strings.TrimSpace(tenant.Name)
|
||||
} else if organization == "" {
|
||||
organization = tenantID
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(extractTraitString(identity.Traits, "email"))
|
||||
phone := strings.TrimSpace(extractTraitString(identity.Traits, "phone"))
|
||||
role := normalizeUserRole(extractTraitString(identity.Traits, "role"))
|
||||
if role == "" {
|
||||
role = domain.RoleUser
|
||||
}
|
||||
reason := strings.TrimSpace(reqBody.Reason)
|
||||
if reason == "" {
|
||||
reason = "직접 부여"
|
||||
}
|
||||
|
||||
existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, existing := range existingRequests {
|
||||
if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch existing.Status {
|
||||
case domain.DeveloperRequestStatusApproved:
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
return c.JSON(existing)
|
||||
case domain.DeveloperRequestStatusPending:
|
||||
if err := h.DeveloperSvc.ApproveRequest(c.Context(), existing.ID, reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
existing.Status = domain.DeveloperRequestStatusApproved
|
||||
existing.AdminNotes = reqBody.AdminNotes
|
||||
return c.JSON(existing)
|
||||
}
|
||||
}
|
||||
|
||||
grant := domain.DeveloperRequest{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
Organization: organization,
|
||||
Email: email,
|
||||
Phone: phone,
|
||||
Role: role,
|
||||
Reason: reason,
|
||||
AccessPages: reqBody.AccessPages,
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
AdminNotes: strings.TrimSpace(reqBody.AdminNotes),
|
||||
}
|
||||
if err := h.DeveloperSvc.CreateGrant(c.Context(), grant); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(grant)
|
||||
}
|
||||
|
||||
func (h *DevHandler) RevokeDeveloperGrant(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid grant id")
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
}
|
||||
if err := c.BodyParser(&reqBody); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id))
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch grant details")
|
||||
}
|
||||
if devReq.Status != domain.DeveloperRequestStatusApproved {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "only approved grants can be revoked")
|
||||
}
|
||||
|
||||
if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
|
||||
@@ -48,6 +48,42 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "activeMember",
|
||||
"valueType": "boolean",
|
||||
"value": "true",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "score",
|
||||
"valueType": "number",
|
||||
"value": "1",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "featureList",
|
||||
"valueType": "array",
|
||||
"value": `["default"]`,
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "preferences",
|
||||
"valueType": "object",
|
||||
"value": `{"theme":"light","density":"comfortable"}`,
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contractDate",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvedAt",
|
||||
"valueType": "datetime",
|
||||
"value": "2026-06-09T09:30",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
@@ -60,8 +96,16 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "A" &&
|
||||
row.Metadata["activeMember"] == false &&
|
||||
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"]) &&
|
||||
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["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
|
||||
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["featureList_permissions"].(map[string]any)["writePermission"] == "admin_only"
|
||||
})).Return(nil).Once()
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
@@ -87,6 +131,15 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "A",
|
||||
"activeMember": false,
|
||||
"score": 42,
|
||||
"featureList": []string{"sso", "claims"},
|
||||
"preferences": map[string]any{
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
@@ -109,6 +162,100 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataAdminUpsertRequiresRPManage(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
t.Run("tenant grant does not allow rp user metadata admin upsert", func(t *testing.T) {
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe()
|
||||
keto := new(devMockKetoService)
|
||||
keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(false, nil)
|
||||
keto.On("CheckPermission", mock.Anything, "User:operator-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: keto,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
keto.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("rp manage allows rp user metadata admin upsert", func(t *testing.T) {
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "B"
|
||||
})).Return(nil).Once()
|
||||
keto := new(devMockKetoService)
|
||||
keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: keto,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
repo.AssertExpectations(t)
|
||||
keto.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
@@ -148,6 +295,7 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
|
||||
identityWriter := service.NewIdentityWriteService(kratos, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -155,6 +303,7 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
IdentityWriter: identityWriter,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
@@ -183,6 +332,145 @@ 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" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "internalRank",
|
||||
"valueType": "text",
|
||||
"value": "S",
|
||||
"readPermission": "admin_only",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
t.Run("rejects admin_only claim", func(t *testing.T) {
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"internalRank": "A"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("allows user_and_admin claim for self", func(t *testing.T) {
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
UserID: "user-1",
|
||||
Metadata: domain.JSONMap{
|
||||
"internalRank": "S",
|
||||
"internalRank_permissions": map[string]any{
|
||||
"readPermission": "admin_only",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "B" &&
|
||||
row.Metadata["internalRank"] == "S"
|
||||
})).Return(nil).Once()
|
||||
kratos := new(MockKratosAdmin)
|
||||
kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
var capturedTraits map[string]any
|
||||
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
IdentityWriter: service.NewIdentityWriteService(kratos, nil),
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
rpClaims := capturedTraits["rp_custom_claims"].(map[string]any)
|
||||
clientClaims := rpClaims["client-1"].(domain.JSONMap)
|
||||
require.Equal(t, "B", clientClaims["approvalLevel"])
|
||||
require.Equal(t, "S", clientClaims["internalRank"])
|
||||
repo.AssertExpectations(t)
|
||||
kratos.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
@@ -276,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"
|
||||
|
||||
@@ -71,10 +72,10 @@ func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) {
|
||||
func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) {
|
||||
args := m.Called(ctx, userID, tenantID)
|
||||
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
|
||||
return req, args.Error(1)
|
||||
if status, ok := args.Get(0).(*domain.DeveloperAccessStatus); ok {
|
||||
return status, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
@@ -87,14 +88,19 @@ func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, userID, status)
|
||||
func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, userID, status, tenantID)
|
||||
if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok {
|
||||
return requests, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error {
|
||||
args := m.Called(ctx, req)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
@@ -337,6 +343,97 @@ func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) {
|
||||
assert.Equal(t, "existing-user", body["user_id"])
|
||||
}
|
||||
|
||||
func TestListMyTenants_UserIncludesPrimaryJoinedAndMetadataAppointments(t *testing.T) {
|
||||
primaryTenantID := "tenant-primary"
|
||||
mockAuth := new(devMockAuthProvider)
|
||||
mockTenant := new(MockTenantService)
|
||||
handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants)
|
||||
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &primaryTenantID,
|
||||
JoinedTenants: []domain.Tenant{
|
||||
{ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": "tenant-extra"},
|
||||
map[string]any{"tenantSlug": "slug-extra"},
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
|
||||
ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive,
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, "tenant-extra").Return(&domain.Tenant{
|
||||
ID: "tenant-extra", Slug: "extra-id", Name: "Extra Tenant By ID", Status: domain.TenantStatusActive,
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "slug-extra").Return(&domain.Tenant{
|
||||
ID: "tenant-slug-extra", Slug: "slug-extra", Name: "Extra Tenant By Slug", Status: domain.TenantStatusActive,
|
||||
}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var tenants []domain.Tenant
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assertTenantIDs(t, tenants, []string{"tenant-primary", "tenant-joined", "tenant-extra", "tenant-slug-extra"})
|
||||
mockAuth.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListMyTenants_TenantAdminIncludesManageableJoinedAndPrimary(t *testing.T) {
|
||||
primaryTenantID := "tenant-primary"
|
||||
mockAuth := new(devMockAuthProvider)
|
||||
mockTenant := new(MockTenantService)
|
||||
handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants)
|
||||
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "tenant-admin-1",
|
||||
Role: "tenant_admin",
|
||||
TenantID: &primaryTenantID,
|
||||
JoinedTenants: []domain.Tenant{
|
||||
{ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive},
|
||||
},
|
||||
}
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once()
|
||||
mockTenant.On("ListManageableTenants", mock.Anything, "tenant-admin-1").Return([]domain.Tenant{
|
||||
{ID: "tenant-managed", Slug: "managed", Name: "Managed Tenant", Status: domain.TenantStatusActive},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
|
||||
ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive,
|
||||
}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var tenants []domain.Tenant
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assertTenantIDs(t, tenants, []string{"tenant-managed", "tenant-joined", "tenant-primary"})
|
||||
mockAuth.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func assertTenantIDs(t *testing.T, tenants []domain.Tenant, expected []string) {
|
||||
t.Helper()
|
||||
|
||||
actual := make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
actual = append(actual, tenant.ID)
|
||||
}
|
||||
assert.ElementsMatch(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestListClients_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
@@ -475,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
|
||||
@@ -525,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",
|
||||
@@ -542,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",
|
||||
@@ -604,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",
|
||||
@@ -672,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",
|
||||
@@ -689,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",
|
||||
@@ -1585,10 +1682,8 @@ func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisi
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
|
||||
developerSvc := new(devMockDeveloperService)
|
||||
developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{
|
||||
UserID: "user-1",
|
||||
TenantID: "tenant-a",
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperAccessStatus{
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -2082,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 offline_access email", captured.Scope)
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||
assert.Contains(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 offline_access email", captured.Scope)
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
|
||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||
}
|
||||
|
||||
func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
@@ -2426,6 +2646,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
"value": "2",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"id": "claim-3",
|
||||
"namespace": "rp_claims",
|
||||
"key": "ratio",
|
||||
"value": "3.14",
|
||||
"valueType": "float",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2436,7 +2663,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 3) {
|
||||
first, ok := claims[0].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "rp_claims", first["namespace"])
|
||||
@@ -2454,6 +2681,14 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
assert.Equal(t, "2", second["value"])
|
||||
assert.Equal(t, "number", second["valueType"])
|
||||
}
|
||||
|
||||
third, ok := claims[2].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "rp_claims", third["namespace"])
|
||||
assert.Equal(t, "ratio", third["key"])
|
||||
assert.Equal(t, "3.14", third["value"])
|
||||
assert.Equal(t, "float", third["valueType"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3331,6 +3566,32 @@ func TestNormalizeIDTokenClaimsMetadata_AllowsDateAndDatetime(t *testing.T) {
|
||||
assert.Equal(t, "datetime", claims[1].ValueType)
|
||||
}
|
||||
|
||||
func TestNormalizeIDTokenClaimsMetadata_PreservesNullableDefaultValue(t *testing.T) {
|
||||
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
|
||||
domain.MetadataIDTokenClaims: []any{
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"value": "",
|
||||
"valueType": "date",
|
||||
"nullable": true,
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim)
|
||||
if assert.Len(t, claims, 1) {
|
||||
assert.Equal(t, "contract_date", claims[0].Key)
|
||||
assert.Equal(t, "date", claims[0].ValueType)
|
||||
assert.True(t, claims[0].Nullable)
|
||||
assert.Equal(t, "user_and_admin", claims[0].ReadPermission)
|
||||
assert.Equal(t, "user_and_admin", claims[0].WritePermission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClient_RejectsTopLevelIDTokenClaimsFromDevConsole(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
|
||||
347
backend/internal/handler/rp_claims_e2e_test.go
Normal file
347
backend/internal/handler/rp_claims_e2e_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type rpClaimsE2ERepo struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*domain.RPUserMetadata
|
||||
getKeys []string
|
||||
}
|
||||
|
||||
func newRPClaimsE2ERepo() *rpClaimsE2ERepo {
|
||||
return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}}
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := rpClaimsE2ERepoKey(clientID, userID)
|
||||
r.getKeys = append(r.getKeys, key)
|
||||
row, ok := r.rows[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata not found")
|
||||
}
|
||||
return cloneRPUserMetadata(row), nil
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := rpClaimsE2ERepoKey(clientID, userID)
|
||||
for _, seen := range r.getKeys {
|
||||
if seen == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rpClaimsE2ERepoKey(clientID, userID string) string {
|
||||
return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID)
|
||||
}
|
||||
|
||||
func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := &domain.RPUserMetadata{
|
||||
ClientID: row.ClientID,
|
||||
UserID: row.UserID,
|
||||
Metadata: domain.JSONMap{},
|
||||
}
|
||||
for key, value := range row.Metadata {
|
||||
cloned.Metadata[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
const userID = "user-rp-e2e"
|
||||
const clientA = "client-rp-a"
|
||||
const clientB = "client-rp-b"
|
||||
|
||||
clients := map[string]map[string]any{
|
||||
clientA: rpClaimsE2EClient(clientA, []map[string]any{
|
||||
rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"),
|
||||
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", 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{
|
||||
rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"),
|
||||
}),
|
||||
}
|
||||
challenges := map[string]string{
|
||||
"challenge-client-a-default": clientA,
|
||||
"challenge-client-a-admin-update": clientA,
|
||||
"challenge-client-a-self-update": clientA,
|
||||
"challenge-client-b-default": clientB,
|
||||
"challenge-client-b-update": clientB,
|
||||
}
|
||||
capturedClaims := map[string]map[string]any{}
|
||||
hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||
clientID := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
client, ok := clients[clientID]
|
||||
if !ok {
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, client), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" {
|
||||
challenge := r.URL.Query().Get("consent_challenge")
|
||||
clientID, ok := challenges[challenge]
|
||||
if !ok {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": challenge,
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": userID,
|
||||
"client": clients[clientID],
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" {
|
||||
challenge := r.URL.Query().Get("consent_challenge")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
_ = json.Unmarshal(body, &acceptReq)
|
||||
session, _ := acceptReq["session"].(map[string]any)
|
||||
idToken, _ := session["id_token"].(map[string]any)
|
||||
capturedClaims[challenge] = idToken
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
repo := newRPClaimsE2ERepo()
|
||||
kratos := new(MockKratosAdminService)
|
||||
kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "rp-e2e@example.com",
|
||||
"name": "RP E2E User",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
authHandler := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: hydraClient,
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
devHandler := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: hydraClient,
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser})
|
||||
return devHandler.SelfUpdateRPUserMetadata(c)
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return devHandler.UpsertRPUserMetadata(c)
|
||||
})
|
||||
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
|
||||
|
||||
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
|
||||
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",
|
||||
"activeMember": false,
|
||||
"score": 42,
|
||||
"featureList": []string{"sso", "claims"},
|
||||
"preferences": map[string]any{"theme": "dark", "density": "compact"},
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"adminManagedNote": "admin-updated",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
})
|
||||
|
||||
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
|
||||
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")
|
||||
|
||||
rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"adminManagedNote": "user-should-not-overwrite",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode)
|
||||
|
||||
allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "C",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
|
||||
|
||||
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
|
||||
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", 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")
|
||||
|
||||
upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{
|
||||
"approvalLevel": "B-rp-only",
|
||||
"activeMember": true,
|
||||
})
|
||||
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
|
||||
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")
|
||||
|
||||
require.True(t, repo.seenGet(clientA, userID))
|
||||
require.True(t, repo.seenGet(clientB, userID))
|
||||
require.Contains(t, capturedClaims, "challenge-client-a-admin-update")
|
||||
require.Contains(t, capturedClaims, "challenge-client-b-update")
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"client_id": clientID,
|
||||
"client_name": clientID,
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-rp-e2e",
|
||||
"id_token_claims": claims,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rpClaimsE2EClaim(key string, valueType string, value any, readPermission string, writePermission string) map[string]any {
|
||||
return map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": key,
|
||||
"valueType": valueType,
|
||||
"value": value,
|
||||
"readPermission": readPermission,
|
||||
"writePermission": writePermission,
|
||||
}
|
||||
}
|
||||
|
||||
func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": challenge,
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
idToken, ok := capturedClaims[challenge]
|
||||
require.True(t, ok)
|
||||
rpClaims, ok := idToken["rp_claims"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
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()
|
||||
|
||||
resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{
|
||||
"metadata": metadata,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
91
backend/internal/handler/test_server_helper_test.go
Normal file
91
backend/internal/handler/test_server_helper_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to bind test server listener: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
server.Listener = ln
|
||||
server.Start()
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func newJWKSHTTPClient(t *testing.T, jwksBody []byte) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/.well-known/jwks.json" {
|
||||
return httpResponse(r, http.StatusOK, string(jwksBody)), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func installKratosWhoamiClient(t *testing.T, identityID string) string {
|
||||
t.Helper()
|
||||
|
||||
origDefaultClient := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "missing session"), nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := httpResponse(r, http.StatusOK, string(body))
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
return resp, nil
|
||||
}),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefaultClient
|
||||
})
|
||||
|
||||
return "http://kratos.test"
|
||||
}
|
||||
|
||||
func jwksURL() string {
|
||||
u := &url.URL{Scheme: "http", Host: "jwks.test", Path: "/.well-known/jwks.json"}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func mustJSONBody(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal test body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -170,6 +171,145 @@ func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]an
|
||||
return metadata
|
||||
}
|
||||
|
||||
func removeUserTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
|
||||
if tenant == nil {
|
||||
return metadata
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
|
||||
targetID := strings.ToLower(strings.TrimSpace(tenant.ID))
|
||||
targetSlug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||
matchesTarget := func(raw any) bool {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
tenantID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
|
||||
tenantSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
|
||||
if tenantSlug == "" {
|
||||
tenantSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
|
||||
}
|
||||
return (targetID != "" && tenantID == targetID) ||
|
||||
(targetSlug != "" && tenantSlug == targetSlug)
|
||||
}
|
||||
|
||||
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
|
||||
appointments = incoming
|
||||
}
|
||||
|
||||
filtered := make([]any, 0, len(appointments))
|
||||
removedPrimary := false
|
||||
for _, appointment := range appointments {
|
||||
if matchesTarget(appointment) {
|
||||
if value, ok := metadataBoolFromMap(appointment.(map[string]any), "isPrimary", "primary", "representative", "isRepresentative"); ok && value {
|
||||
removedPrimary = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, appointment)
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
traits["additionalAppointments"] = filtered
|
||||
metadata["additionalAppointments"] = filtered
|
||||
} else {
|
||||
delete(traits, "additionalAppointments")
|
||||
delete(metadata, "additionalAppointments")
|
||||
}
|
||||
|
||||
delete(traits, tenant.ID)
|
||||
delete(metadata, tenant.ID)
|
||||
if primaryTenantID := strings.ToLower(normalizeMetadataString(traits["primaryTenantId"])); primaryTenantID == targetID && targetID != "" {
|
||||
removedPrimary = true
|
||||
}
|
||||
if primaryTenantSlug := strings.ToLower(normalizeMetadataString(traits["primaryTenantSlug"])); primaryTenantSlug == targetSlug && targetSlug != "" {
|
||||
removedPrimary = true
|
||||
}
|
||||
if removedPrimary {
|
||||
delete(traits, "primaryTenantId")
|
||||
delete(traits, "primaryTenantSlug")
|
||||
delete(traits, "primaryTenantName")
|
||||
delete(traits, "primaryTenantIsOwner")
|
||||
delete(metadata, "primaryTenantId")
|
||||
delete(metadata, "primaryTenantSlug")
|
||||
delete(metadata, "primaryTenantName")
|
||||
delete(metadata, "primaryTenantIsOwner")
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
func userMetadataRecordFromAny(value any) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return typed
|
||||
case domain.JSONMap:
|
||||
return map[string]any(typed)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enforceGlobalCustomClaimWritePermissions(traits map[string]any, metadata map[string]any, isAdmin bool) error {
|
||||
if isAdmin || metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
incomingClaims := userMetadataRecordFromAny(metadata["global_custom_claims"])
|
||||
if incomingClaims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingClaims := userMetadataRecordFromAny(traits["global_custom_claims"])
|
||||
existingPermissions := userMetadataRecordFromAny(traits["global_custom_claim_permissions"])
|
||||
existingTypes := userMetadataRecordFromAny(traits["global_custom_claim_types"])
|
||||
|
||||
claimKeys := map[string]bool{}
|
||||
for key := range incomingClaims {
|
||||
claimKeys[strings.TrimSpace(key)] = true
|
||||
}
|
||||
for key := range existingClaims {
|
||||
claimKeys[strings.TrimSpace(key)] = true
|
||||
}
|
||||
|
||||
for key := range claimKeys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
incomingValue, incomingExists := incomingClaims[key]
|
||||
existingValue, existingExists := existingClaims[key]
|
||||
if incomingExists && existingExists && reflect.DeepEqual(incomingValue, existingValue) {
|
||||
continue
|
||||
}
|
||||
if !incomingExists && !existingExists {
|
||||
continue
|
||||
}
|
||||
|
||||
permission := "admin_only"
|
||||
if rawPermission := userMetadataRecordFromAny(existingPermissions[key]); rawPermission != nil {
|
||||
permission = normalizeCustomClaimPermission(rawPermission["writePermission"])
|
||||
}
|
||||
if permission != "user_and_admin" {
|
||||
return fmt.Errorf("global custom claim %s is admin only", key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(existingPermissions) > 0 {
|
||||
metadata["global_custom_claim_permissions"] = existingPermissions
|
||||
}
|
||||
if len(existingTypes) > 0 {
|
||||
metadata["global_custom_claim_types"] = existingTypes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
|
||||
if tenantService == nil || metadata == nil {
|
||||
return false, nil
|
||||
@@ -1864,6 +2004,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
Role *string `json:"role"`
|
||||
TenantSlug *string `json:"tenantSlug"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
IsAddTenant bool `json:"isAddTenant"`
|
||||
Department *string `json:"department"`
|
||||
Grade *string `json:"grade"`
|
||||
Position *string `json:"position"`
|
||||
@@ -1950,6 +2091,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Prepare updates
|
||||
traits := identity.Traits
|
||||
oldRoleForSync := roleFromTraits(traits)
|
||||
oldTenantIDForSync := extractTraitString(traits, "tenant_id")
|
||||
if req.Role != nil {
|
||||
traits["role"] = *req.Role
|
||||
}
|
||||
@@ -1957,8 +2100,30 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
|
||||
// Resolve and update tenant_id in traits if changed
|
||||
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||
if req.IsAddTenant {
|
||||
if h.TenantService == nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "tenant service not available"})
|
||||
continue
|
||||
}
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
||||
if err != nil || tenant == nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
|
||||
continue
|
||||
}
|
||||
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
|
||||
if appointments, ok := metadata["additionalAppointments"]; ok {
|
||||
traits["additionalAppointments"] = appointments
|
||||
}
|
||||
if h.KetoOutboxRepo != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + id,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||
traits["tenant_id"] = tItem.ID
|
||||
} else if h.TenantService != nil {
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
||||
@@ -1990,7 +2155,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
|
||||
if err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||
continue
|
||||
@@ -1998,9 +2163,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Sync to local DB
|
||||
if h.UserRepo != nil {
|
||||
localUser := h.mapToLocalUser(*identity)
|
||||
oldRole := roleFromTraits(identity.Traits)
|
||||
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
|
||||
localUser := h.mapToLocalUser(*updated)
|
||||
|
||||
if req.Role != nil {
|
||||
localUser.Role = *req.Role
|
||||
@@ -2035,7 +2198,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
// [Keto Sync]
|
||||
if h.KetoOutboxRepo != nil {
|
||||
h.syncKetoRole(c.Context(), localUser.ID,
|
||||
localUser.Role, oldRole, oldTenantID, localUser.TenantID)
|
||||
localUser.Role, oldRoleForSync, oldTenantIDForSync, localUser.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2241,6 +2404,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
// [Validation] Based on Tenant Schema (Multi-tenant aware)
|
||||
isAdmin := requester != nil && requester.Role == domain.RoleSuperAdmin
|
||||
if err := enforceGlobalCustomClaimWritePermissions(identity.Traits, req.Metadata, isAdmin); err != nil {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: "+err.Error())
|
||||
}
|
||||
|
||||
// If metadata is namespaced (key is tenant ID), validate each namespace
|
||||
// If it's flat, validate using schemaCompCode
|
||||
@@ -2329,26 +2495,22 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
|
||||
if req.IsRemoveTenant {
|
||||
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
|
||||
go func(removedSlug string) {
|
||||
bgCtx := context.Background()
|
||||
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
||||
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}(code)
|
||||
}
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
currentTenantID := extractTraitString(traits, "tenant_id")
|
||||
if currentTenantID == tenant.ID {
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
req.Metadata = removeUserTenantAppointment(traits, req.Metadata, tenant)
|
||||
if h.KetoOutboxRepo != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !req.IsAddTenant {
|
||||
|
||||
@@ -1400,6 +1400,84 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
KetoOutboxRepo: mockOutbox,
|
||||
}
|
||||
app.Put("/users/bulk", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return h.BulkUpdateUsers(c)
|
||||
})
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "u1@test.com",
|
||||
"name": "Bulk User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "team-a").Return(&domain.Tenant{
|
||||
ID: "team-a-id",
|
||||
Name: "Team A",
|
||||
Slug: "team-a",
|
||||
}, nil).Once()
|
||||
mockKratos.On(
|
||||
"UpdateIdentity",
|
||||
mock.Anything,
|
||||
"u-1",
|
||||
mock.MatchedBy(func(traits map[string]any) bool {
|
||||
if extractTraitString(traits, "tenant_id") != "primary-tenant-id" {
|
||||
return false
|
||||
}
|
||||
appointments, ok := traits["additionalAppointments"].([]any)
|
||||
if !ok || len(appointments) != 1 {
|
||||
return false
|
||||
}
|
||||
appointment, ok := appointments[0].(map[string]any)
|
||||
return ok &&
|
||||
appointment["tenantId"] == "team-a-id" &&
|
||||
appointment["tenantSlug"] == "team-a" &&
|
||||
appointment["tenantName"] == "Team A"
|
||||
}),
|
||||
mock.Anything,
|
||||
).Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "u1@test.com",
|
||||
"name": "Bulk User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "team-a-id" &&
|
||||
entry.Relation == "members" &&
|
||||
entry.Subject == "User:u-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil).Once()
|
||||
|
||||
body := `{"userIds":["u-1"],"tenantSlug":"team-a","isAddTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1702,6 +1780,137 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_GlobalCustomClaimWritePermission(t *testing.T) {
|
||||
newApp := func(t *testing.T, existingPermission string, updateIdentity bool) (*fiber.App, *MockKratosAdmin, *MockTenantServiceForUser, *map[string]any) {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
capturedTraits := map[string]any(nil)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
tenantID := "t-123"
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "requester-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: tenantID, Slug: "test-tenant"},
|
||||
},
|
||||
})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Custom Claim User",
|
||||
"tenant_id": tenantID,
|
||||
"global_custom_claims": map[string]any{
|
||||
"contract_date": "2026-06-09",
|
||||
},
|
||||
"global_custom_claim_types": map[string]any{
|
||||
"contract_date": "date",
|
||||
},
|
||||
"global_custom_claim_permissions": map[string]any{
|
||||
"contract_date": map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": existingPermission,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []any{},
|
||||
},
|
||||
}, nil).Maybe()
|
||||
if updateIdentity {
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Custom Claim User",
|
||||
},
|
||||
}, nil).Once()
|
||||
} else {
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{},
|
||||
}, nil).Maybe()
|
||||
}
|
||||
|
||||
return app, mockKratos, mockTenant, &capturedTraits
|
||||
}
|
||||
|
||||
requestBody := func(nextValue string) *bytes.Reader {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"global_custom_claims": map[string]any{
|
||||
"contract_date": nextValue,
|
||||
},
|
||||
"global_custom_claim_types": map[string]any{
|
||||
"contract_date": "date",
|
||||
},
|
||||
"global_custom_claim_permissions": map[string]any{
|
||||
"contract_date": map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return bytes.NewReader(body)
|
||||
}
|
||||
|
||||
t.Run("regular user cannot change admin_only global custom claim value", func(t *testing.T) {
|
||||
app, mockKratos, mockTenant, _ := newApp(t, "admin_only", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("regular user can change user_and_admin global custom claim value", func(t *testing.T) {
|
||||
app, mockKratos, mockTenant, capturedTraits := newApp(t, "user_and_admin", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotNil(t, *capturedTraits)
|
||||
claims := (*capturedTraits)["global_custom_claims"].(map[string]any)
|
||||
require.Equal(t, "2026-07-01", claims["contract_date"])
|
||||
permissions := (*capturedTraits)["global_custom_claim_permissions"].(map[string]any)
|
||||
require.Equal(t, map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
}, permissions["contract_date"])
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_AcceptsDeprecatedAdminRolesAsUser(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -2569,6 +2778,117 @@ func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testin
|
||||
require.Equal(t, false, added["isPrimary"])
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRemoveTenantDropsAdditionalAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
KetoOutboxRepo: mockOutbox,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": "private-team-id",
|
||||
"tenantSlug": "private-team",
|
||||
"tenantName": "비공개 팀",
|
||||
"isPrimary": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
|
||||
ID: "primary-tenant-id",
|
||||
Name: "대표 조직",
|
||||
Slug: "primary-tenant",
|
||||
}, nil).Maybe()
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "private-team-id" &&
|
||||
entry.Relation == "members" &&
|
||||
entry.Subject == "User:user-id" &&
|
||||
entry.Action == domain.KetoOutboxActionDelete
|
||||
})).Return(nil).Maybe()
|
||||
|
||||
var capturedTraits map[string]any
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
body := `{"tenantSlug":"private-team","isRemoveTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "primary-tenant-id", capturedTraits["tenant_id"])
|
||||
appointments, ok := capturedTraits["additionalAppointments"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, appointments, 1)
|
||||
remaining := appointments[0].(map[string]any)
|
||||
require.Equal(t, "primary-tenant-id", remaining["tenantId"])
|
||||
require.Equal(t, "primary-tenant", remaining["tenantSlug"])
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantRejectsUnmanageableTenantForTenantAdmin(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
Reference in New Issue
Block a user