forked from baron/baron-sso
custom claim 타입보정 UI. 대표테넌트 노출 보정
This commit is contained in:
@@ -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] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
backend/internal/handler/rp_claims_e2e_test.go
Normal file
328
backend/internal/handler/rp_claims_e2e_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user