1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions

This commit is contained in:
2026-06-12 20:28:18 +09:00
148 changed files with 11895 additions and 2024 deletions

View File

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

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -511,7 +512,7 @@ func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *tes
assert.NotContains(t, claims, "lead_tenants")
}
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -579,7 +580,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
"approvalLevel": "A",
"internalMemo": "관리자 전용",
},
}, nil).Once()
}, nil).Maybe()
h.RPUserMetadataRepo = repo
app := fiber.New()
@@ -597,14 +598,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
assert.True(t, ok)
assert.Len(t, rpProfiles, 1)
profile := rpProfiles[0].(map[string]any)
assert.Equal(t, "client-app", profile["client_id"])
fields := profile["fields"].(map[string]any)
assert.Equal(t, "A", fields["approvalLevel"])
assert.NotContains(t, fields, "internalMemo")
assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest())
repo.AssertExpectations(t)
}
@@ -728,7 +722,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-configured-claims",
"requested_scope": []string{"openid", "profile"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-789",
"client": map[string]any{
"client_id": "client-configured-claims",
@@ -823,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)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View 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
}

View File

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

View File

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