1
0
forked from baron/baron-sso

offline 스코프 제거, rp_claims 값 표준화

This commit is contained in:
2026-06-11 14:50:26 +09:00
parent f60b15a17b
commit c495e9119b
26 changed files with 1034 additions and 300 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View 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");
});
});

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

View File

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

View File

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

View File

@@ -105,14 +105,6 @@ Tenant/RP 단위로 묶어서 전달한다.
"employeeNo": "E1001"
}
}
],
"rp_profiles": [
{
"client_id": "sample-rp",
"fields": {
"approvalLevel": "A"
}
}
]
}
```

View File

@@ -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;
```

View File

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

View File

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

View File

@@ -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 = "활성화된 세션만 보려면 토글을 켜주세요."

View File

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

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

View File

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

View File

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

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