forked from baron/baron-sso
offline 스코프 제거, rp_claims 값 표준화
This commit is contained in:
@@ -1202,7 +1202,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
if includeTenantDetails {
|
||||
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
|
||||
if namespaced, ok := traits[tenantID].(map[string]any); ok {
|
||||
maps.Copy(claims, namespaced)
|
||||
maps.Copy(claims, sanitizeTenantClaimMetadata(namespaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1213,11 +1213,11 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
|
||||
// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
|
||||
for k, v := range traits {
|
||||
if k == "metadata" || k == "global_custom_claims" || k == "global_custom_claim_types" || k == "global_custom_claim_permissions" {
|
||||
if isReservedTenantTraitKey(k) {
|
||||
continue
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
allTenants[k] = m
|
||||
allTenants[k] = sanitizeTenantClaimMetadata(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,7 +1272,7 @@ func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) m
|
||||
if key == "" || value == nil {
|
||||
continue
|
||||
}
|
||||
if key == "rp_claims" || key == "rp_profiles" {
|
||||
if isReservedTopLevelCustomClaimKey(key) {
|
||||
continue
|
||||
}
|
||||
if _, exists := baseClaims[key]; exists {
|
||||
@@ -1322,6 +1322,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tenantClaim = sanitizeTenantClaimMetadata(tenantClaim)
|
||||
|
||||
tenant, ancestors, inHanmacFamily := h.resolveHanmacFamilyTenantClaimAncestry(ctx, tenantKey)
|
||||
if !inHanmacFamily || tenant == nil {
|
||||
@@ -1613,9 +1614,12 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
|
||||
}
|
||||
|
||||
for _, claim := range normalizedClaims {
|
||||
if claim.Namespace == "rp_claims" && isReservedRPClaimKey(claim.Key) {
|
||||
continue
|
||||
}
|
||||
if claim.Nullable && strings.TrimSpace(claim.Value) == "" {
|
||||
if claim.Namespace == "rp_claims" {
|
||||
rpClaims[claim.Key] = nil
|
||||
rpClaims[claim.Key] = buildRPClaimPayload(nil, claim, nil)
|
||||
continue
|
||||
}
|
||||
if _, exists := baseClaims[claim.Key]; !exists {
|
||||
@@ -1631,7 +1635,7 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
|
||||
}
|
||||
|
||||
if claim.Namespace == "rp_claims" {
|
||||
rpClaims[claim.Key] = value
|
||||
rpClaims[claim.Key] = buildRPClaimPayload(value, claim, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1677,6 +1681,9 @@ func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[s
|
||||
}
|
||||
|
||||
for _, claim := range rpClaimDefinitions {
|
||||
if isReservedRPClaimKey(claim.Key) {
|
||||
continue
|
||||
}
|
||||
raw, ok := row.Metadata[claim.Key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
@@ -1686,7 +1693,7 @@ func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[s
|
||||
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
|
||||
continue
|
||||
}
|
||||
rpClaims[claim.Key] = value
|
||||
rpClaims[claim.Key] = buildRPClaimPayload(value, claim, row.Metadata[claim.Key+"_permissions"])
|
||||
}
|
||||
|
||||
if len(rpClaims) > 0 {
|
||||
@@ -1723,6 +1730,92 @@ func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim
|
||||
return definitions
|
||||
}
|
||||
|
||||
func isReservedTopLevelCustomClaimKey(key string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(key), "rp_")
|
||||
}
|
||||
|
||||
func isReservedRPClaimKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "rp_") {
|
||||
return true
|
||||
}
|
||||
switch key {
|
||||
case "", "tenant_id", "tenants", "joined_tenants", "lead_tenants":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isReservedTenantTraitKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "rp_") {
|
||||
return true
|
||||
}
|
||||
switch key {
|
||||
case "metadata",
|
||||
"global_custom_claims",
|
||||
"global_custom_claim_types",
|
||||
"global_custom_claim_permissions":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRPClaimRelatedTenantMetadataKey(key string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(key), "rp_")
|
||||
}
|
||||
|
||||
func sanitizeTenantClaimMetadata(raw map[string]any) map[string]any {
|
||||
cleaned := make(map[string]any, len(raw))
|
||||
for key, value := range raw {
|
||||
if isRPClaimRelatedTenantMetadataKey(key) {
|
||||
continue
|
||||
}
|
||||
cleaned[key] = sanitizeTenantClaimValue(value)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func sanitizeTenantClaimValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return sanitizeTenantClaimMetadata(typed)
|
||||
case []any:
|
||||
items := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, sanitizeTenantClaimValue(item))
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func buildRPClaimPayload(value any, claim normalizedIDTokenClaim, rawPermission any) map[string]any {
|
||||
readPermission := normalizeCustomClaimPermission(claim.ReadPermission)
|
||||
writePermission := normalizeCustomClaimPermission(claim.WritePermission)
|
||||
|
||||
if permissions, ok := rawPermission.(map[string]any); ok {
|
||||
if rawRead := readInterfaceString(permissions["readPermission"], ""); rawRead != "" {
|
||||
readPermission = normalizeCustomClaimPermission(rawRead)
|
||||
}
|
||||
if rawWrite := readInterfaceString(permissions["writePermission"], ""); rawWrite != "" {
|
||||
writePermission = normalizeCustomClaimPermission(rawWrite)
|
||||
}
|
||||
}
|
||||
if writePermission == "user_and_admin" {
|
||||
readPermission = "user_and_admin"
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"value": value,
|
||||
"readPermission": readPermission,
|
||||
"writePermission": writePermission,
|
||||
}
|
||||
}
|
||||
|
||||
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
@@ -1752,6 +1845,12 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
return value, true
|
||||
}
|
||||
case float64:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
if value == math.Trunc(value) {
|
||||
return value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if valueType == "float" {
|
||||
return value, true
|
||||
}
|
||||
@@ -1760,6 +1859,12 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
}
|
||||
case float32:
|
||||
floatValue := float64(value)
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
if floatValue == math.Trunc(floatValue) {
|
||||
return floatValue, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if valueType == "float" {
|
||||
return floatValue, true
|
||||
}
|
||||
@@ -1767,6 +1872,9 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
return floatValue, true
|
||||
}
|
||||
case int:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
@@ -1774,6 +1882,9 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
return float64(value), true
|
||||
}
|
||||
case int64:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
@@ -1781,6 +1892,10 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
return float64(value), true
|
||||
}
|
||||
case json.Number:
|
||||
if valueType == "date" || valueType == "datetime" {
|
||||
parsed, err := value.Int64()
|
||||
return float64(parsed), err == nil
|
||||
}
|
||||
if valueType == "number" {
|
||||
parsed, err := value.Int64()
|
||||
return float64(parsed), err == nil
|
||||
@@ -1795,120 +1910,6 @@ func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
}
|
||||
if h == nil || h.RPUserMetadataRepo == nil {
|
||||
return claims
|
||||
}
|
||||
|
||||
clientID := strings.TrimSpace(client.ClientID)
|
||||
subject = strings.TrimSpace(subject)
|
||||
if clientID == "" || subject == "" {
|
||||
return claims
|
||||
}
|
||||
|
||||
claimKeys := extractClaimEnabledCustomUserSchemaKeys(client.Metadata)
|
||||
if len(claimKeys) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
|
||||
if err != nil || row == nil || len(row.Metadata) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
fields := make(map[string]any)
|
||||
for _, key := range claimKeys {
|
||||
raw, ok := row.Metadata[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
if value, ok := raw.(string); ok {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
fields[key] = value
|
||||
continue
|
||||
}
|
||||
fields[key] = raw
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
profile := map[string]any{
|
||||
"client_id": clientID,
|
||||
"fields": fields,
|
||||
}
|
||||
if existing, ok := claims["rp_profiles"].([]any); ok {
|
||||
claims["rp_profiles"] = append(existing, profile)
|
||||
return claims
|
||||
}
|
||||
if existing, ok := claims["rp_profiles"].([]any); ok {
|
||||
claims["rp_profiles"] = append(existing, profile)
|
||||
return claims
|
||||
}
|
||||
claims["rp_profiles"] = []any{profile}
|
||||
return claims
|
||||
}
|
||||
|
||||
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]any) []string {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
rawSchema, ok := metadata["customUserSchema"]
|
||||
if !ok || rawSchema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []any
|
||||
switch schema := rawSchema.(type) {
|
||||
case []any:
|
||||
items = schema
|
||||
case []map[string]any:
|
||||
items = make([]any, 0, len(schema))
|
||||
for _, item := range schema {
|
||||
items = append(items, item)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
field, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
if typed, typedOK := item.(map[string]any); typedOK {
|
||||
field = typed
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
enabled, _ := field["claimEnabled"].(bool)
|
||||
if !enabled {
|
||||
enabled, _ = field["claim_enabled"].(bool)
|
||||
}
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
key, _ := field["key"].(string)
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
||||
emails := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
@@ -6196,7 +6197,6 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
||||
if err == nil {
|
||||
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
|
||||
@@ -6235,7 +6235,6 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
@@ -6427,7 +6426,6 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
@@ -8432,7 +8430,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append([]string{"openid"}, scopes...) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -511,7 +512,7 @@ func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *tes
|
||||
assert.NotContains(t, claims, "lead_tenants")
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||
func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -579,7 +580,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||
"approvalLevel": "A",
|
||||
"internalMemo": "관리자 전용",
|
||||
},
|
||||
}, nil).Once()
|
||||
}, nil).Maybe()
|
||||
h.RPUserMetadataRepo = repo
|
||||
|
||||
app := fiber.New()
|
||||
@@ -597,14 +598,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
|
||||
assert.True(t, ok)
|
||||
assert.Len(t, rpProfiles, 1)
|
||||
profile := rpProfiles[0].(map[string]any)
|
||||
assert.Equal(t, "client-app", profile["client_id"])
|
||||
fields := profile["fields"].(map[string]any)
|
||||
assert.Equal(t, "A", fields["approvalLevel"])
|
||||
assert.NotContains(t, fields, "internalMemo")
|
||||
assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest())
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -728,7 +722,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-configured-claims",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-789",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-configured-claims",
|
||||
@@ -823,8 +817,13 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, float64(2), rpClaims["tier"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
|
||||
tier := rpClaims["tier"].(map[string]any)
|
||||
assert.Equal(t, float64(2), tier["value"])
|
||||
assert.Equal(t, "admin_only", tier["readPermission"])
|
||||
assert.Equal(t, "admin_only", tier["writePermission"])
|
||||
|
||||
features := rpClaims["features"].(map[string]any)
|
||||
assert.Equal(t, []any{"sso", "claims"}, features["value"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,7 +834,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-rp-user-claims",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"subject": "user-rp-claims",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-rp-claims",
|
||||
@@ -883,6 +882,14 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
"value": "2026-06-09T09:30",
|
||||
"valueType": "datetime",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "tenants",
|
||||
"value": "must-not-shadow-tenants",
|
||||
"valueType": "text",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -916,8 +923,34 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
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",
|
||||
@@ -931,8 +964,8 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"internalMemo": "must-not-leak",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"readPermission": "admin_only",
|
||||
@@ -947,7 +980,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-rp-user-claims",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -959,16 +992,58 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
assert.NotNil(t, capturedClaims)
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "B", rpClaims["approvalLevel"])
|
||||
assert.Equal(t, false, rpClaims["activeMember"])
|
||||
assert.Equal(t, float64(42), rpClaims["score"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
|
||||
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
|
||||
approvalLevel := rpClaims["approvalLevel"].(map[string]any)
|
||||
assert.Equal(t, "B", approvalLevel["value"])
|
||||
assert.Equal(t, "user_and_admin", approvalLevel["readPermission"])
|
||||
assert.Equal(t, "user_and_admin", approvalLevel["writePermission"])
|
||||
|
||||
activeMember := rpClaims["activeMember"].(map[string]any)
|
||||
assert.Equal(t, false, activeMember["value"])
|
||||
|
||||
score := rpClaims["score"].(map[string]any)
|
||||
assert.Equal(t, float64(42), score["value"])
|
||||
|
||||
featureList := rpClaims["featureList"].(map[string]any)
|
||||
assert.Equal(t, []any{"sso", "claims"}, featureList["value"])
|
||||
|
||||
preferences := rpClaims["preferences"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, preferences["value"])
|
||||
|
||||
contractDate := rpClaims["contractDate"].(map[string]any)
|
||||
assert.Equal(t, float64(1781017200), contractDate["value"])
|
||||
|
||||
approvedAt := rpClaims["approvedAt"].(map[string]any)
|
||||
assert.Equal(t, float64(1780968600), approvedAt["value"])
|
||||
|
||||
assert.NotContains(t, rpClaims, "tenants")
|
||||
assert.NotContains(t, rpClaims, "internalMemo")
|
||||
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
|
||||
}
|
||||
assert.NotContains(t, capturedClaims, "rp_profiles")
|
||||
assert.NotContains(t, capturedClaims["joined_tenants"], "rp_custom_claims")
|
||||
tenants := capturedClaims["tenants"].(map[string]any)
|
||||
assert.Contains(t, tenants, "tenant-leaf")
|
||||
assert.NotEqual(t, "must-not-shadow-tenants", capturedClaims["tenants"])
|
||||
assertNoRPClaimDataInTenantClaims(t, tenants)
|
||||
assert.NotContains(t, capturedClaims, legacyProfileArrayClaimKeyForTest())
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func legacyProfileArrayClaimKeyForTest() string {
|
||||
return strings.Join([]string{"rp", "profiles"}, "_")
|
||||
}
|
||||
|
||||
func assertNoRPClaimDataInTenantClaims(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
assert.False(t, strings.HasPrefix(key, "rp_"))
|
||||
assertNoRPClaimDataInTenantClaims(t, child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
assertNoRPClaimDataInTenantClaims(t, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
|
||||
appendIfPresent := func(scope string) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
|
||||
for _, scope := range combined {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -135,6 +136,42 @@ func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
|
||||
}
|
||||
|
||||
func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) {
|
||||
client := domain.HydraClient{
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "offline", "mandatory": true},
|
||||
{"name": "offline_access", "locked": true},
|
||||
{"name": "email", "mandatory": true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeRequestedScopesWithClientRequirements(
|
||||
client,
|
||||
[]string{"openid", "offline", "profile", "offline_access"},
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged)
|
||||
}
|
||||
|
||||
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
|
||||
urlString := buildHydraAuthorizationURL(
|
||||
"client-refresh",
|
||||
[]string{"offline", "profile", "offline_access", "email"},
|
||||
[]string{"https://rp.example.com/callback"},
|
||||
)
|
||||
|
||||
parsed, err := url.Parse(urlString)
|
||||
assert.NoError(t, err)
|
||||
scopes := parsed.Query().Get("scope")
|
||||
|
||||
assert.Equal(t, "openid profile email", scopes)
|
||||
assert.NotContains(t, scopes, "offline")
|
||||
assert.NotContains(t, scopes, "offline_access")
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -2099,7 +2100,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required")
|
||||
}
|
||||
|
||||
scopes := derefSlice(req.Scopes, defaultClientScopes())
|
||||
scopes := normalizeClientScopes(derefSlice(req.Scopes, defaultClientScopes()))
|
||||
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
|
||||
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
|
||||
|
||||
@@ -2186,7 +2187,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
RedirectURIs: redirectURIs,
|
||||
GrantTypes: grantTypes,
|
||||
ResponseTypes: responseTypes,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
Scope: buildScope(scopes),
|
||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||
SkipConsent: new(valueOrBool(req.SkipConsent, true)),
|
||||
JWKSUri: jwksURI,
|
||||
@@ -3593,7 +3594,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
|
||||
value := strings.TrimSpace(readClaimValueString(record["value"], ""))
|
||||
nullable, _ := record["nullable"].(bool)
|
||||
if !(nullable && value == "") {
|
||||
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
|
||||
@@ -3631,6 +3632,35 @@ func readInterfaceString(value any, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func readClaimValueString(value any, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case float64:
|
||||
if typed == math.Trunc(typed) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
floatValue := float64(typed)
|
||||
if floatValue == math.Trunc(floatValue) {
|
||||
return strconv.FormatInt(int64(floatValue), 10)
|
||||
}
|
||||
return strconv.FormatFloat(floatValue, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
trimmed := strings.TrimSpace(rawValue)
|
||||
|
||||
@@ -3703,21 +3733,36 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("date value is required")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", trimmed); err != nil {
|
||||
return nil, errors.New("date value must use YYYY-MM-DD")
|
||||
if isIntegerClaimLiteral(trimmed) {
|
||||
parsed, err := strconv.ParseInt(trimmed, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("date value must use unix seconds or YYYY-MM-DD")
|
||||
}
|
||||
return trimmed, nil
|
||||
return parsed, 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")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
return parsed, nil
|
||||
}
|
||||
return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm")
|
||||
if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
||||
return parsed.Unix(), nil
|
||||
}
|
||||
if parsed, err := time.ParseInLocation("2006-01-02T15:04", trimmed, time.UTC); err == nil {
|
||||
return parsed.Unix(), nil
|
||||
}
|
||||
return nil, errors.New("datetime value must use unix seconds, RFC3339, or YYYY-MM-DDTHH:mm")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
|
||||
}
|
||||
@@ -3795,7 +3840,33 @@ func defaultResponseTypes() []string {
|
||||
}
|
||||
|
||||
func buildScope(scopes []string) string {
|
||||
return strings.Join(scopes, " ")
|
||||
return strings.Join(normalizeClientScopes(scopes), " ")
|
||||
}
|
||||
|
||||
func normalizeClientScopes(scopes []string) []string {
|
||||
normalized := make([]string, 0, len(scopes))
|
||||
seen := make(map[string]struct{}, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isRefreshTokenScopeAlias(scope string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "offline", "offline_access":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func valueOr(ptr *string, fallback string) string {
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
row.Metadata["score"] == float64(42) &&
|
||||
assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) &&
|
||||
assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) &&
|
||||
row.Metadata["contractDate"] == "2026-06-10" &&
|
||||
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
|
||||
rpMetadataNumberEquals(row.Metadata["contractDate"], 1781017200) &&
|
||||
rpMetadataNumberEquals(row.Metadata["approvedAt"], 1780968600) &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
|
||||
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
@@ -138,8 +138,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
@@ -332,6 +332,21 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func rpMetadataNumberEquals(value any, want int64) bool {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return typed == want
|
||||
case int:
|
||||
return int64(typed) == want
|
||||
case float64:
|
||||
return typed == float64(want)
|
||||
case float32:
|
||||
return float64(typed) == float64(want)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevHandler_SelfUpdateRPUserMetadataHonorsWritePermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
@@ -549,3 +564,21 @@ func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestNormalizeIDTokenClaimsMetadataAcceptsUnixDateDefaults(t *testing.T) {
|
||||
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
|
||||
"id_token_claims": []any{
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": float64(1781020800),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
claims := metadata["id_token_claims"].([]normalizedIDTokenClaim)
|
||||
require.Len(t, claims, 1)
|
||||
require.Equal(t, "1781020800", claims[0].Value)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -571,7 +572,7 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"status": "active"},
|
||||
}), nil
|
||||
@@ -621,7 +622,7 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -638,7 +639,7 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -700,7 +701,7 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -768,7 +769,7 @@ func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -785,7 +786,7 @@ func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -2176,6 +2177,131 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
assert.False(t, hasRequestObjectAlg)
|
||||
}
|
||||
|
||||
func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
err = json.Unmarshal(body, &captured)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
||||
"client_id": captured.ClientID,
|
||||
"client_name": captured.ClientName,
|
||||
"redirect_uris": captured.RedirectURIs,
|
||||
"grant_types": captured.GrantTypes,
|
||||
"response_types": captured.ResponseTypes,
|
||||
"scope": captured.Scope,
|
||||
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
||||
"skip_consent": captured.SkipConsent,
|
||||
"metadata": captured.Metadata,
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Refresh Token App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||
"scopes": []string{"openid", "offline", "profile", "offline_access", "email"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "openid profile email", captured.Scope)
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
|
||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||
}
|
||||
|
||||
func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-refresh" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-refresh",
|
||||
"client_name": "Refresh Token App",
|
||||
"redirect_uris": []string{"https://rp.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid offline profile offline_access email",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"status": "active"},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-refresh" {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
err = json.Unmarshal(body, &captured)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": captured.ClientID,
|
||||
"client_name": captured.ClientName,
|
||||
"redirect_uris": captured.RedirectURIs,
|
||||
"grant_types": captured.GrantTypes,
|
||||
"response_types": captured.ResponseTypes,
|
||||
"scope": captured.Scope,
|
||||
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
||||
"skip_consent": captured.SkipConsent,
|
||||
"metadata": captured.Metadata,
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Refresh Token App Updated",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "openid profile email", captured.Scope)
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
|
||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||
}
|
||||
|
||||
func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("contractDate", "date", float64(1780930800), "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("approvedAt", "datetime", float64(1780965000), "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
|
||||
}),
|
||||
clientB: rpClaimsE2EClient(clientB, []map[string]any{
|
||||
@@ -188,13 +188,14 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
|
||||
|
||||
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
|
||||
assert.Equal(t, "A", initialA["approvalLevel"])
|
||||
assert.Equal(t, true, initialA["activeMember"])
|
||||
assert.Equal(t, float64(1), initialA["score"])
|
||||
assert.Equal(t, []any{"default"}, initialA["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
|
||||
assert.Equal(t, "2026-06-09", initialA["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
|
||||
assert.Equal(t, "A", rpClaimValue(t, initialA, "approvalLevel"))
|
||||
assert.Equal(t, "user_and_admin", rpClaimPermission(t, initialA, "approvalLevel", "readPermission"))
|
||||
assert.Equal(t, true, rpClaimValue(t, initialA, "activeMember"))
|
||||
assert.Equal(t, float64(1), rpClaimValue(t, initialA, "score"))
|
||||
assert.Equal(t, []any{"default"}, rpClaimValue(t, initialA, "featureList"))
|
||||
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, rpClaimValue(t, initialA, "preferences"))
|
||||
assert.Equal(t, float64(1780930800), rpClaimValue(t, initialA, "contractDate"))
|
||||
assert.Equal(t, float64(1780965000), rpClaimValue(t, initialA, "approvedAt"))
|
||||
|
||||
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
|
||||
"approvalLevel": "B",
|
||||
@@ -202,8 +203,8 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
"score": 42,
|
||||
"featureList": []string{"sso", "claims"},
|
||||
"preferences": map[string]any{"theme": "dark", "density": "compact"},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"contractDate": float64(1781017200),
|
||||
"approvedAt": float64(1780968600),
|
||||
"adminManagedNote": "admin-updated",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
@@ -211,14 +212,14 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
})
|
||||
|
||||
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
|
||||
assert.Equal(t, "B", updatedA["approvalLevel"])
|
||||
assert.Equal(t, false, updatedA["activeMember"])
|
||||
assert.Equal(t, float64(42), updatedA["score"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
|
||||
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
|
||||
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
|
||||
assert.Equal(t, "B", rpClaimValue(t, updatedA, "approvalLevel"))
|
||||
assert.Equal(t, false, rpClaimValue(t, updatedA, "activeMember"))
|
||||
assert.Equal(t, float64(42), rpClaimValue(t, updatedA, "score"))
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaimValue(t, updatedA, "featureList"))
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaimValue(t, updatedA, "preferences"))
|
||||
assert.Equal(t, float64(1781017200), rpClaimValue(t, updatedA, "contractDate"))
|
||||
assert.Equal(t, float64(1780968600), rpClaimValue(t, updatedA, "approvedAt"))
|
||||
assert.Equal(t, "admin-updated", rpClaimValue(t, updatedA, "adminManagedNote"))
|
||||
assert.NotContains(t, updatedA, "approvalLevel_permissions")
|
||||
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
|
||||
|
||||
@@ -237,12 +238,12 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
|
||||
|
||||
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
|
||||
assert.Equal(t, "C", selfUpdatedA["approvalLevel"])
|
||||
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
|
||||
assert.Equal(t, "C", rpClaimValue(t, selfUpdatedA, "approvalLevel"))
|
||||
assert.Equal(t, "admin-updated", rpClaimValue(t, selfUpdatedA, "adminManagedNote"))
|
||||
|
||||
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
|
||||
assert.Equal(t, "B-default", defaultB["approvalLevel"])
|
||||
assert.Equal(t, false, defaultB["activeMember"])
|
||||
assert.Equal(t, "B-default", rpClaimValue(t, defaultB, "approvalLevel"))
|
||||
assert.Equal(t, false, rpClaimValue(t, defaultB, "activeMember"))
|
||||
assert.NotContains(t, defaultB, "score")
|
||||
assert.NotContains(t, defaultB, "featureList")
|
||||
assert.NotContains(t, defaultB, "adminManagedNote")
|
||||
@@ -252,9 +253,9 @@ func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
"activeMember": true,
|
||||
})
|
||||
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
|
||||
assert.Equal(t, "B-rp-only", updatedB["approvalLevel"])
|
||||
assert.Equal(t, true, updatedB["activeMember"])
|
||||
assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"])
|
||||
assert.Equal(t, "B-rp-only", rpClaimValue(t, updatedB, "approvalLevel"))
|
||||
assert.Equal(t, true, rpClaimValue(t, updatedB, "activeMember"))
|
||||
assert.NotEqual(t, rpClaimValue(t, selfUpdatedA, "approvalLevel"), rpClaimValue(t, updatedB, "approvalLevel"))
|
||||
assert.NotContains(t, updatedB, "score")
|
||||
assert.NotContains(t, updatedB, "featureList")
|
||||
|
||||
@@ -276,7 +277,7 @@ func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any
|
||||
}
|
||||
}
|
||||
|
||||
func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any {
|
||||
func rpClaimsE2EClaim(key string, valueType string, value any, readPermission string, writePermission string) map[string]any {
|
||||
return map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": key,
|
||||
@@ -307,6 +308,24 @@ func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[s
|
||||
return rpClaims
|
||||
}
|
||||
|
||||
func rpClaimValue(t *testing.T, claims map[string]any, key string) any {
|
||||
t.Helper()
|
||||
|
||||
payload, ok := claims[key].(map[string]any)
|
||||
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
|
||||
return payload["value"]
|
||||
}
|
||||
|
||||
func rpClaimPermission(t *testing.T, claims map[string]any, key string, permissionKey string) string {
|
||||
t.Helper()
|
||||
|
||||
payload, ok := claims[key].(map[string]any)
|
||||
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
|
||||
value, ok := payload[permissionKey].(string)
|
||||
require.Truef(t, ok, "rp_claims.%s.%s must be a string", key, permissionKey)
|
||||
return value
|
||||
}
|
||||
|
||||
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func tenantNamespaceIDsFromTraits(traits map[string]any) []string {
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
for key, value := range traits {
|
||||
if key == "" || key == "metadata" {
|
||||
if key == "" || isReservedTenantTraitKey(key) {
|
||||
continue
|
||||
}
|
||||
switch value.(type) {
|
||||
|
||||
@@ -48,6 +48,12 @@ import {
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import {
|
||||
claimDateTimeValueToInputString,
|
||||
dateTimeInputToUnixSeconds,
|
||||
getBrowserTimeZone,
|
||||
getSupportedTimeZones,
|
||||
} from "./rpClaimDateTime";
|
||||
|
||||
type RPClaimValueType =
|
||||
| "text"
|
||||
@@ -72,6 +78,7 @@ type MetadataDraftRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
timeZone: string;
|
||||
valueType: RPClaimValueType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
@@ -101,6 +108,7 @@ function readPermissionMetadata(
|
||||
function metadataToDraftRows(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
schemas: RPClaimSchema[],
|
||||
defaultTimeZone = getBrowserTimeZone(),
|
||||
): MetadataDraftRow[] {
|
||||
if (schemas.length > 0) {
|
||||
return schemas.map((schema) => ({
|
||||
@@ -110,7 +118,9 @@ function metadataToDraftRows(
|
||||
metadata?.[schema.key],
|
||||
schema.value,
|
||||
schema.valueType,
|
||||
defaultTimeZone,
|
||||
),
|
||||
timeZone: defaultTimeZone,
|
||||
valueType: schema.valueType,
|
||||
readPermission: readPermissionMetadata(
|
||||
metadata,
|
||||
@@ -134,6 +144,7 @@ function metadataToDraftRows(
|
||||
id: `${key}-${index}`,
|
||||
key,
|
||||
value: metadataValueToString(value, ""),
|
||||
timeZone: defaultTimeZone,
|
||||
valueType: "text",
|
||||
readPermission: readPermissionMetadata(
|
||||
metadata,
|
||||
@@ -176,6 +187,11 @@ function draftRowValueToMetadataValue(row: MetadataDraftRow) {
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true";
|
||||
case "date":
|
||||
case "datetime":
|
||||
return (
|
||||
dateTimeInputToUnixSeconds(value, row.valueType, row.timeZone) ?? value
|
||||
);
|
||||
case "array":
|
||||
if (value === "") return [];
|
||||
try {
|
||||
@@ -225,14 +241,20 @@ function metadataValueToInputString(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
valueType: RPClaimValueType,
|
||||
timeZone: string,
|
||||
) {
|
||||
const text = metadataValueToString(value, fallback);
|
||||
if (valueType === "date") {
|
||||
return text.slice(0, 10);
|
||||
return claimDateTimeValueToInputString(value, fallback, "date", timeZone);
|
||||
}
|
||||
if (valueType === "datetime") {
|
||||
return text.slice(0, 16);
|
||||
return claimDateTimeValueToInputString(
|
||||
value,
|
||||
fallback,
|
||||
"datetime",
|
||||
timeZone,
|
||||
);
|
||||
}
|
||||
const text = metadataValueToString(value, fallback);
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -299,6 +321,11 @@ function ClientConsentsPage() {
|
||||
const [metadataDraftRows, setMetadataDraftRows] = useState<
|
||||
MetadataDraftRow[]
|
||||
>([]);
|
||||
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
|
||||
const timeZoneOptions = useMemo(
|
||||
() => getSupportedTimeZones(browserTimeZone),
|
||||
[browserTimeZone],
|
||||
);
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
@@ -351,10 +378,14 @@ function ClientConsentsPage() {
|
||||
useEffect(() => {
|
||||
if (metadataQuery.data) {
|
||||
setMetadataDraftRows(
|
||||
metadataToDraftRows(metadataQuery.data.metadata, rpClaimSchemas),
|
||||
metadataToDraftRows(
|
||||
metadataQuery.data.metadata,
|
||||
rpClaimSchemas,
|
||||
browserTimeZone,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [metadataQuery.data, rpClaimSchemas]);
|
||||
}, [browserTimeZone, metadataQuery.data, rpClaimSchemas]);
|
||||
|
||||
const handleRevoke = (sub: string) => {
|
||||
if (
|
||||
@@ -1029,6 +1060,7 @@ function ClientConsentsPage() {
|
||||
aria-label={`${row.key} ${row.valueType}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
inputMode={rpClaimInputMode(row.valueType)}
|
||||
@@ -1046,6 +1078,26 @@ function ClientConsentsPage() {
|
||||
)}
|
||||
aria-label={`${row.key} ${row.valueType}`}
|
||||
/>
|
||||
{(row.valueType === "date" ||
|
||||
row.valueType === "datetime") && (
|
||||
<select
|
||||
value={row.timeZone}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
timeZone: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={`${row.key} timezone`}
|
||||
>
|
||||
{timeZoneOptions.map((timeZone) => (
|
||||
<option key={timeZone} value={timeZone}>
|
||||
{timeZone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={row.readPermission}
|
||||
|
||||
@@ -207,6 +207,7 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
@@ -436,6 +437,56 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
|
||||
expect(container.querySelector('input[type="date"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('select[aria-label="Claim 기본값 시간대"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("saves date RP claim default values as Unix seconds for the selected timezone", async () => {
|
||||
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
|
||||
locale: "ko-KR",
|
||||
calendar: "gregory",
|
||||
numberingSystem: "latn",
|
||||
timeZone: "Asia/Seoul",
|
||||
} as Intl.ResolvedDateTimeFormatOptions);
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
|
||||
|
||||
const defaultValueInput =
|
||||
container.querySelector<HTMLInputElement>('input[type="date"]');
|
||||
expect(defaultValueInput).not.toBeNull();
|
||||
await setInputValue(defaultValueInput as HTMLInputElement, "2026-06-10");
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) =>
|
||||
button.textContent?.includes("저장") ||
|
||||
button.textContent?.includes("Save"),
|
||||
);
|
||||
expect(saveButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(updateClientMock).toHaveBeenCalledWith(
|
||||
"client-claims",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
id_token_claims: [
|
||||
expect.objectContaining({
|
||||
value: 1781017200,
|
||||
valueType: "date",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks saving an object RP claim default value that is not a JSON object", async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
@@ -58,6 +58,12 @@ import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
||||
import {
|
||||
claimDateTimeValueToInputString,
|
||||
dateTimeInputToUnixSeconds,
|
||||
getBrowserTimeZone,
|
||||
getSupportedTimeZones,
|
||||
} from "./rpClaimDateTime";
|
||||
|
||||
interface ScopeItem {
|
||||
id: string;
|
||||
@@ -84,6 +90,7 @@ interface IdTokenClaimItem {
|
||||
namespace: ClaimNamespace;
|
||||
key: string;
|
||||
value: string;
|
||||
timeZone: string;
|
||||
valueType: ClaimValueType;
|
||||
nullable: boolean;
|
||||
readPermission: CustomClaimPermission;
|
||||
@@ -171,6 +178,7 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||||
namespace: "rp_claims",
|
||||
key: "",
|
||||
value: "",
|
||||
timeZone: getBrowserTimeZone(),
|
||||
valueType: "text",
|
||||
nullable: false,
|
||||
readPermission: "admin_only",
|
||||
@@ -215,23 +223,32 @@ function readIdTokenClaimsMetadata(
|
||||
}
|
||||
const keyValue = typeof record.key === "string" ? record.key : "";
|
||||
const rawValue = record.value;
|
||||
const valueValue =
|
||||
typeof rawValue === "string"
|
||||
? rawValue
|
||||
: rawValue == null
|
||||
? ""
|
||||
: JSON.stringify(rawValue);
|
||||
const valueTypeValue =
|
||||
typeof record.valueType === "string" &&
|
||||
isClaimValueType(record.valueType)
|
||||
? record.valueType
|
||||
: "text";
|
||||
const timeZoneValue = getBrowserTimeZone();
|
||||
const valueValue =
|
||||
valueTypeValue === "date" || valueTypeValue === "datetime"
|
||||
? claimDateTimeValueToInputString(
|
||||
rawValue,
|
||||
"",
|
||||
valueTypeValue,
|
||||
timeZoneValue,
|
||||
)
|
||||
: typeof rawValue === "string"
|
||||
? rawValue
|
||||
: rawValue == null
|
||||
? ""
|
||||
: JSON.stringify(rawValue);
|
||||
|
||||
return normalizeIdTokenClaimPermissions({
|
||||
id: `claim-${index + 1}`,
|
||||
namespace: namespaceValue,
|
||||
key: keyValue,
|
||||
value: valueValue,
|
||||
timeZone: timeZoneValue,
|
||||
valueType: valueTypeValue,
|
||||
nullable: record.nullable === true,
|
||||
readPermission: isCustomClaimPermission(record.readPermission)
|
||||
@@ -249,11 +266,21 @@ function normalizeClaimPreviewValue(
|
||||
value: string,
|
||||
valueType: ClaimValueType,
|
||||
nullable: boolean,
|
||||
timeZone: string,
|
||||
): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (nullable && trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
if (valueType === "date" || valueType === "datetime") {
|
||||
if (trimmed === "") return "";
|
||||
const unixSeconds = dateTimeInputToUnixSeconds(
|
||||
trimmed,
|
||||
valueType,
|
||||
timeZone,
|
||||
);
|
||||
return unixSeconds ?? trimmed;
|
||||
}
|
||||
if (valueType === "number" || valueType === "float") {
|
||||
if (trimmed === "") return "";
|
||||
const parsed = Number(trimmed);
|
||||
@@ -320,6 +347,19 @@ function isValidDateTimeInputValue(value: string) {
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
function normalizedClaimValue(claim: IdTokenClaimItem): string | number {
|
||||
const value = claim.value.trim();
|
||||
if (claim.valueType !== "date" && claim.valueType !== "datetime") {
|
||||
return value;
|
||||
}
|
||||
if (value === "") {
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
dateTimeInputToUnixSeconds(value, claim.valueType, claim.timeZone) ?? value
|
||||
);
|
||||
}
|
||||
|
||||
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
|
||||
const value = claim.value.trim();
|
||||
if (value === "") {
|
||||
@@ -440,6 +480,7 @@ function buildIdTokenClaimsPreview(
|
||||
item.value,
|
||||
item.valueType,
|
||||
item.nullable,
|
||||
item.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -612,6 +653,11 @@ function ClientGeneralPage() {
|
||||
},
|
||||
]);
|
||||
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
|
||||
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
|
||||
const timeZoneOptions = useMemo(
|
||||
() => getSupportedTimeZones(browserTimeZone),
|
||||
[browserTimeZone],
|
||||
);
|
||||
|
||||
const tenantScopeDescription = t(
|
||||
"msg.dev.clients.scopes.tenant",
|
||||
@@ -985,13 +1031,20 @@ function ClientGeneralPage() {
|
||||
"허용 알고리즘: {{algorithms}}",
|
||||
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||||
);
|
||||
const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
|
||||
const normalizedIdTokenClaimItems = idTokenClaims.map((claim) =>
|
||||
normalizeIdTokenClaimPermissions({
|
||||
...claim,
|
||||
key: claim.key.trim(),
|
||||
value: claim.value.trim(),
|
||||
}),
|
||||
);
|
||||
const normalizedIdTokenClaims = normalizedIdTokenClaimItems.map((claim) => {
|
||||
const { timeZone: _timeZone, value: _value, ...persisted } = claim;
|
||||
return {
|
||||
...persisted,
|
||||
value: normalizedClaimValue(claim),
|
||||
};
|
||||
});
|
||||
|
||||
if (headlessLoginEnabled) {
|
||||
if (!trimmedJwksUri) {
|
||||
@@ -1048,7 +1101,7 @@ function ClientGeneralPage() {
|
||||
|
||||
const claimValidationErrors: string[] = [];
|
||||
const seenClaimKeys = new Set<string>();
|
||||
for (const claim of normalizedIdTokenClaims) {
|
||||
for (const claim of normalizedIdTokenClaimItems) {
|
||||
if (!claim.key) {
|
||||
claimValidationErrors.push(
|
||||
t(
|
||||
@@ -1087,7 +1140,7 @@ function ClientGeneralPage() {
|
||||
|
||||
const hasValidationErrors = validationErrors.length > 0;
|
||||
const idTokenClaimPreview = buildIdTokenClaimsPreview(
|
||||
normalizedIdTokenClaims,
|
||||
normalizedIdTokenClaimItems,
|
||||
);
|
||||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||
@@ -2529,6 +2582,7 @@ function ClientGeneralPage() {
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type={claimDefaultInputType(claim.valueType)}
|
||||
inputMode={claimDefaultInputMode(
|
||||
@@ -2555,6 +2609,32 @@ function ClientGeneralPage() {
|
||||
defaultValueError ? true : undefined
|
||||
}
|
||||
/>
|
||||
{(claim.valueType === "date" ||
|
||||
claim.valueType === "datetime") && (
|
||||
<select
|
||||
value={claim.timeZone}
|
||||
onChange={(event) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"timeZone",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.timezone_label",
|
||||
"Claim 기본값 시간대",
|
||||
)}
|
||||
>
|
||||
{timeZoneOptions.map((timeZone) => (
|
||||
<option key={timeZone} value={timeZone}>
|
||||
{timeZone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{defaultValueError && (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
|
||||
32
devfront/src/features/clients/rpClaimDateTime.test.ts
Normal file
32
devfront/src/features/clients/rpClaimDateTime.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
claimDateTimeValueToInputString,
|
||||
dateTimeInputToUnixSeconds,
|
||||
unixSecondsToDateTimeInput,
|
||||
} from "./rpClaimDateTime";
|
||||
|
||||
describe("rpClaimDateTime", () => {
|
||||
it("converts date and datetime input in a selected timezone to Unix seconds", () => {
|
||||
expect(dateTimeInputToUnixSeconds("2026-06-10", "date", "Asia/Seoul")).toBe(
|
||||
1781017200,
|
||||
);
|
||||
expect(
|
||||
dateTimeInputToUnixSeconds("2026-06-09T10:30", "datetime", "Asia/Seoul"),
|
||||
).toBe(1780968600);
|
||||
});
|
||||
|
||||
it("formats stored Unix seconds for the selected timezone", () => {
|
||||
expect(unixSecondsToDateTimeInput(1781017200, "date", "Asia/Seoul")).toBe(
|
||||
"2026-06-10",
|
||||
);
|
||||
expect(
|
||||
unixSecondsToDateTimeInput(1780968600, "datetime", "Asia/Seoul"),
|
||||
).toBe("2026-06-09T10:30");
|
||||
});
|
||||
|
||||
it("uses Unix seconds values when hydrating date inputs", () => {
|
||||
expect(
|
||||
claimDateTimeValueToInputString(1780968600, "", "datetime", "Asia/Seoul"),
|
||||
).toBe("2026-06-09T10:30");
|
||||
});
|
||||
});
|
||||
137
devfront/src/features/clients/rpClaimDateTime.ts
Normal file
137
devfront/src/features/clients/rpClaimDateTime.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export type RPClaimDateTimeValueType = "date" | "datetime";
|
||||
|
||||
export const FALLBACK_TIME_ZONE = "UTC";
|
||||
|
||||
export function getBrowserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || FALLBACK_TIME_ZONE;
|
||||
}
|
||||
|
||||
export function getSupportedTimeZones(currentTimeZone = getBrowserTimeZone()) {
|
||||
const supported =
|
||||
typeof Intl.supportedValuesOf === "function"
|
||||
? Intl.supportedValuesOf("timeZone")
|
||||
: [];
|
||||
return Array.from(
|
||||
new Set([currentTimeZone, FALLBACK_TIME_ZONE, ...supported]),
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeZoneOffsetMs(date: Date, timeZone: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(
|
||||
parts
|
||||
.filter((part) => part.type !== "literal")
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
const hour = values.hour === "24" ? "00" : values.hour;
|
||||
const asUTC = Date.UTC(
|
||||
Number(values.year),
|
||||
Number(values.month) - 1,
|
||||
Number(values.day),
|
||||
Number(hour),
|
||||
Number(values.minute),
|
||||
Number(values.second),
|
||||
);
|
||||
return asUTC - date.getTime();
|
||||
}
|
||||
|
||||
function zonedDateTimeToUnixSeconds(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
timeZone: string,
|
||||
) {
|
||||
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0);
|
||||
let instant = utcGuess - getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
|
||||
const corrected = utcGuess - getTimeZoneOffsetMs(new Date(instant), timeZone);
|
||||
if (corrected !== instant) {
|
||||
instant = corrected;
|
||||
}
|
||||
return Math.trunc(instant / 1000);
|
||||
}
|
||||
|
||||
export function dateTimeInputToUnixSeconds(
|
||||
value: string,
|
||||
valueType: RPClaimDateTimeValueType,
|
||||
timeZone: string,
|
||||
): number | null {
|
||||
const trimmed = value.trim();
|
||||
const match =
|
||||
valueType === "date"
|
||||
? /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed)
|
||||
: /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(trimmed);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const hour = valueType === "datetime" ? Number(match[4]) : 0;
|
||||
const minute = valueType === "datetime" ? Number(match[5]) : 0;
|
||||
const unixSeconds = zonedDateTimeToUnixSeconds(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
timeZone || FALLBACK_TIME_ZONE,
|
||||
);
|
||||
return Number.isFinite(unixSeconds) ? unixSeconds : null;
|
||||
}
|
||||
|
||||
export function unixSecondsToDateTimeInput(
|
||||
value: number,
|
||||
valueType: RPClaimDateTimeValueType,
|
||||
timeZone: string,
|
||||
) {
|
||||
const date = new Date(value * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timeZone || FALLBACK_TIME_ZONE,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(
|
||||
parts
|
||||
.filter((part) => part.type !== "literal")
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
const hour = values.hour === "24" ? "00" : values.hour;
|
||||
const dateText = `${values.year}-${values.month}-${values.day}`;
|
||||
if (valueType === "date") return dateText;
|
||||
return `${dateText}T${hour}:${values.minute}`;
|
||||
}
|
||||
|
||||
export function claimDateTimeValueToInputString(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
valueType: RPClaimDateTimeValueType,
|
||||
timeZone: string,
|
||||
) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return unixSecondsToDateTimeInput(value, valueType, timeZone);
|
||||
}
|
||||
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
||||
return unixSecondsToDateTimeInput(
|
||||
Number(value.trim()),
|
||||
valueType,
|
||||
timeZone,
|
||||
);
|
||||
}
|
||||
const text = typeof value === "string" ? value : fallback;
|
||||
return valueType === "date" ? text.slice(0, 10) : text.slice(0, 16);
|
||||
}
|
||||
@@ -492,8 +492,8 @@ describe("devfront coverage smoke pages", () => {
|
||||
expect(settings.textContent).not.toContain("top-level");
|
||||
expect(settings.textContent).toContain("Date");
|
||||
expect(settings.textContent).toContain("Datetime");
|
||||
expect(settings.textContent).toContain("Read");
|
||||
expect(settings.textContent).toContain("Write");
|
||||
expect(settings.textContent).toContain("User read");
|
||||
expect(settings.textContent).toContain("User write");
|
||||
|
||||
const consents = await renderPage(<ClientConsentsPage />, {
|
||||
path: "/clients/:id/consents",
|
||||
|
||||
@@ -94,6 +94,13 @@ test.describe("DevFront consents", () => {
|
||||
await expect(page.getByText("approved_at")).toBeVisible();
|
||||
await expect(page.getByText("active_member")).toBeVisible();
|
||||
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
|
||||
await expect(
|
||||
page.getByLabel(/contract_date.*timezone|timezone.*contract_date/i),
|
||||
).toHaveValue(
|
||||
await page.evaluate(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
),
|
||||
);
|
||||
await page.locator('input[type="date"]').fill("2026-06-10");
|
||||
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
|
||||
await page
|
||||
@@ -107,10 +114,10 @@ test.describe("DevFront consents", () => {
|
||||
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
|
||||
await expect
|
||||
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
|
||||
.toBe("2026-06-10");
|
||||
.toBe(1781017200);
|
||||
await expect
|
||||
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
|
||||
.toBe("2026-06-09T10:30");
|
||||
.toBe(1780968600);
|
||||
await expect
|
||||
.poll(() => state.consents[0]?.rpMetadata?.active_member)
|
||||
.toBe(false);
|
||||
|
||||
@@ -105,14 +105,6 @@ Tenant/RP 단위로 묶어서 전달한다.
|
||||
"employeeNo": "E1001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rp_profiles": [
|
||||
{
|
||||
"client_id": "sample-rp",
|
||||
"fields": {
|
||||
"approvalLevel": "A"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,7 +69,7 @@ hydra clients create
|
||||
--id devfront
|
||||
--grant-types authorization_code,refresh_token
|
||||
--response-types code
|
||||
--scope openid,offline_access,profile,email
|
||||
--scope openid,profile,email
|
||||
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
|
||||
--callbacks http://localhost:5174/auth/callback;
|
||||
```
|
||||
|
||||
@@ -134,7 +134,7 @@ async function loginAdminFront(context: BrowserContext): Promise<Page> {
|
||||
client_id: "adminfront",
|
||||
redirect_uri: `${origin}/auth/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email",
|
||||
scope: "openid profile email",
|
||||
state: `pw-${Date.now()}`,
|
||||
nonce: `pw-${Date.now()}`,
|
||||
code_challenge: "test-code-challenge-test-code-challenge-test",
|
||||
|
||||
@@ -72,7 +72,6 @@ error = "An error occurred while cancelling consent: {error}"
|
||||
|
||||
[msg.userfront.consent.scope]
|
||||
email = "Email address (account identification and notifications)"
|
||||
offline_access = "Offline access (keep signed in)"
|
||||
openid = "OpenID authentication information (signin session check)"
|
||||
phone = "Phone number (identity verification and notifications)"
|
||||
profile = "Basic profile information (name, user identifier)"
|
||||
@@ -706,4 +705,3 @@ toggle_label = "Show active sessions only"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "Toggle to view only active sessions."
|
||||
|
||||
|
||||
@@ -297,7 +297,6 @@ error = "취소 처리 중 오류가 발생했습니다: {error}"
|
||||
|
||||
[msg.userfront.consent.scope]
|
||||
email = "이메일 주소 (계정 식별 및 알림 용도)"
|
||||
offline_access = "오프라인 접근 (로그인 유지)"
|
||||
openid = "OpenID 인증 정보 (로그인 상태 확인)"
|
||||
phone = "휴대폰 번호 (본인 인증 및 알림)"
|
||||
profile = "기본 프로필 정보 (이름, 사용자 식별자)"
|
||||
@@ -927,4 +926,3 @@ toggle_label = "활성 세션만 보기"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
|
||||
@@ -269,7 +269,6 @@ error = ""
|
||||
|
||||
[msg.userfront.consent.scope]
|
||||
email = ""
|
||||
offline_access = ""
|
||||
openid = ""
|
||||
phone = ""
|
||||
profile = ""
|
||||
@@ -899,4 +898,3 @@ toggle_label = ""
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = ""
|
||||
|
||||
|
||||
11
userfront/lib/features/auth/domain/consent_scope_policy.dart
Normal file
11
userfront/lib/features/auth/domain/consent_scope_policy.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
bool isRefreshTokenScopeAlias(String scope) {
|
||||
final normalized = scope.trim().toLowerCase();
|
||||
return normalized == 'offline' || normalized == 'offline_access';
|
||||
}
|
||||
|
||||
List<String> filterConsentScopes(Iterable<String> scopes) {
|
||||
return scopes
|
||||
.map((scope) => scope.trim())
|
||||
.where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_scope_policy.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
@@ -53,10 +54,6 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
'msg.userfront.consent.scope.email',
|
||||
fallback: 'Email address (account identification and notifications)',
|
||||
),
|
||||
'offline_access': tr(
|
||||
'msg.userfront.consent.scope.offline_access',
|
||||
fallback: 'Offline access (keep signed in)',
|
||||
),
|
||||
'phone': tr(
|
||||
'msg.userfront.consent.scope.phone',
|
||||
fallback: 'Phone number (identity verification and notifications)',
|
||||
@@ -80,9 +77,6 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
String _scopeDisplayLabel(String scope) {
|
||||
if (scope == 'offline_access') {
|
||||
return 'offline access';
|
||||
}
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@@ -138,9 +132,11 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final requestedScopes = filterConsentScopes(
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
info['requested_scope'] = requestedScopes;
|
||||
|
||||
setState(() {
|
||||
_consentInfo = info;
|
||||
@@ -299,9 +295,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
final requestedScopes = filterConsentScopes(
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
[],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
|
||||
@@ -597,7 +597,6 @@ const Map<String, String> koStrings = {
|
||||
"msg.userfront.consent.missing_redirect": "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다.",
|
||||
"msg.userfront.consent.redirect_notice": "동의 후 자동으로 서비스로 이동합니다.",
|
||||
"msg.userfront.consent.scope.email": "이메일 주소 (계정 식별 및 알림 용도)",
|
||||
"msg.userfront.consent.scope.offline_access": "오프라인 접근 (로그인 유지)",
|
||||
"msg.userfront.consent.scope.openid": "OpenID 인증 정보 (로그인 상태 확인)",
|
||||
"msg.userfront.consent.scope.phone": "휴대폰 번호 (본인 인증 및 알림)",
|
||||
"msg.userfront.consent.scope.profile": "기본 프로필 정보 (이름, 사용자 식별자)",
|
||||
@@ -2982,8 +2981,6 @@ const Map<String, String> enStrings = {
|
||||
"After consent, you will be redirected automatically.",
|
||||
"msg.userfront.consent.scope.email":
|
||||
"Email address (account identification and notifications)",
|
||||
"msg.userfront.consent.scope.offline_access":
|
||||
"Offline access (keep signed in)",
|
||||
"msg.userfront.consent.scope.openid":
|
||||
"OpenID authentication information (signin session check)",
|
||||
"msg.userfront.consent.scope.phone":
|
||||
|
||||
25
userfront/test/consent_scope_policy_test.dart
Normal file
25
userfront/test/consent_scope_policy_test.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_scope_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('consent scope policy', () {
|
||||
test('filters offline scope aliases from requested consent scopes', () {
|
||||
expect(
|
||||
filterConsentScopes([
|
||||
'openid',
|
||||
' offline ',
|
||||
'profile',
|
||||
'offline_access',
|
||||
'email',
|
||||
]),
|
||||
['openid', 'profile', 'email'],
|
||||
);
|
||||
});
|
||||
|
||||
test('detects refresh token scope aliases case-insensitively', () {
|
||||
expect(isRefreshTokenScopeAlias('OFFLINE'), isTrue);
|
||||
expect(isRefreshTokenScopeAlias(' offline_access '), isTrue);
|
||||
expect(isRefreshTokenScopeAlias('profile'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user