1
0
forked from baron/baron-sso

custom claim 타입보정 UI. 대표테넌트 노출 보정

This commit is contained in:
2026-06-11 11:27:11 +09:00
parent 0bb3ccb850
commit f60b15a17b
37 changed files with 2952 additions and 417 deletions

View File

@@ -17,6 +17,7 @@ import (
"io"
"log/slog"
"maps"
"math"
"math/rand"
"net"
"net/http"
@@ -1646,6 +1647,154 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
return baseClaims
}
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{}
}
if h == nil || h.RPUserMetadataRepo == nil {
return claims
}
clientID := strings.TrimSpace(client.ClientID)
subject = strings.TrimSpace(subject)
if clientID == "" || subject == "" {
return claims
}
rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
if len(rpClaimDefinitions) == 0 {
return claims
}
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
if err != nil || row == nil || len(row.Metadata) == 0 {
return claims
}
rpClaims, _ := claims["rp_claims"].(map[string]any)
if rpClaims == nil {
rpClaims = map[string]any{}
}
for _, claim := range rpClaimDefinitions {
raw, ok := row.Metadata[claim.Key]
if !ok || raw == nil {
continue
}
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
}
rpClaims[claim.Key] = value
}
if len(rpClaims) > 0 {
claims["rp_claims"] = rpClaims
}
return claims
}
func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
if metadata == nil {
return nil
}
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
if !ok || rawClaims == nil {
return nil
}
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 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 == "float" {
return value, true
}
if valueType == "number" && value == math.Trunc(value) {
return value, true
}
case float32:
floatValue := float64(value)
if valueType == "float" {
return floatValue, true
}
if valueType == "number" && floatValue == math.Trunc(floatValue) {
return floatValue, true
}
case int:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case int64:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case json.Number:
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 (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil {
claims = map[string]any{}
@@ -6046,6 +6195,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil {
@@ -6084,6 +6234,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
@@ -6275,6 +6426,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)

View File

@@ -827,3 +827,148 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
}
}
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"},
"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",
},
},
},
},
}), 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",
},
}, nil)
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": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"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"},
})
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) {
assert.Equal(t, "B", rpClaims["approvalLevel"])
assert.Equal(t, false, rpClaims["activeMember"])
assert.Equal(t, float64(42), rpClaims["score"])
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
assert.NotContains(t, rpClaims, "internalMemo")
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
}
assert.NotContains(t, capturedClaims, "rp_profiles")
repo.AssertExpectations(t)
}

View File

@@ -3588,7 +3588,7 @@ 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)
}
@@ -3641,9 +3641,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":
@@ -3708,6 +3723,54 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
}
}
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

View File

@@ -60,6 +60,30 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"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
@@ -74,8 +98,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
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"]) &&
row.Metadata["contractDate"] == "2026-06-10" &&
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
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",
@@ -103,6 +133,13 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"approvalLevel": "A",
"activeMember": false,
"score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},

View File

@@ -2520,6 +2520,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
"value": "2",
"valueType": "number",
},
{
"id": "claim-3",
"namespace": "rp_claims",
"key": "ratio",
"value": "3.14",
"valueType": "float",
},
},
},
})
@@ -2530,7 +2537,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"])
@@ -2548,6 +2555,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"])
}
}
}

View File

@@ -0,0 +1,328 @@
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", "2026-06-09", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "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", initialA["approvalLevel"])
assert.Equal(t, true, initialA["activeMember"])
assert.Equal(t, float64(1), initialA["score"])
assert.Equal(t, []any{"default"}, initialA["featureList"])
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
assert.Equal(t, "2026-06-09", initialA["contractDate"])
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
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": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"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", updatedA["approvalLevel"])
assert.Equal(t, false, updatedA["activeMember"])
assert.Equal(t, float64(42), updatedA["score"])
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
assert.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", selfUpdatedA["approvalLevel"])
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
assert.Equal(t, "B-default", defaultB["approvalLevel"])
assert.Equal(t, false, defaultB["activeMember"])
assert.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", updatedB["approvalLevel"])
assert.Equal(t, true, updatedB["activeMember"])
assert.NotEqual(t, selfUpdatedA["approvalLevel"], 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, valueType, value, readPermission, 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 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
}