From c495e9119b7d4a2c92072c541b8adc36e614bc13 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 11 Jun 2026 14:50:26 +0900 Subject: [PATCH] =?UTF-8?q?offline=20=EC=8A=A4=EC=BD=94=ED=94=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20rp=5Fclaims=20=EA=B0=92=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 248 +++++++++--------- .../auth_handler_dynamic_claims_test.go | 129 +++++++-- .../internal/handler/client_tenant_access.go | 4 +- .../handler/client_tenant_access_test.go | 37 +++ backend/internal/handler/dev_handler.go | 95 ++++++- .../handler/dev_handler_rp_metadata_test.go | 41 ++- backend/internal/handler/dev_handler_test.go | 138 +++++++++- .../internal/handler/rp_claims_e2e_test.go | 73 ++++-- .../handler/tenant_assignment_policy.go | 2 +- .../features/clients/ClientConsentsPage.tsx | 94 +++++-- .../clients/ClientGeneralPage.claims.test.tsx | 51 ++++ .../features/clients/ClientGeneralPage.tsx | 150 ++++++++--- .../features/clients/rpClaimDateTime.test.ts | 32 +++ .../src/features/clients/rpClaimDateTime.ts | 137 ++++++++++ .../src/features/coverage/pageSmoke.test.tsx | 4 +- devfront/tests/devfront-consents.spec.ts | 11 +- docs/custom-field-jsonb-index-policy.md | 8 - docs/devfront_auth_flow_explanation.md | 12 +- .../tests/session-cross-browser-debug.spec.ts | 2 +- userfront/assets/translations/en.toml | 2 - userfront/assets/translations/ko.toml | 2 - userfront/assets/translations/template.toml | 2 - .../auth/domain/consent_scope_policy.dart | 11 + .../auth/presentation/consent_screen.dart | 21 +- userfront/lib/i18n_data.dart | 3 - userfront/test/consent_scope_policy_test.dart | 25 ++ 26 files changed, 1034 insertions(+), 300 deletions(-) create mode 100644 devfront/src/features/clients/rpClaimDateTime.test.ts create mode 100644 devfront/src/features/clients/rpClaimDateTime.ts create mode 100644 userfront/lib/features/auth/domain/consent_scope_policy.dart create mode 100644 userfront/test/consent_scope_policy_test.dart diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 4ca11585..e92041d3 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 { diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index 2f9bd7df..f6d310e8 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -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", + }, }, }, }, @@ -914,10 +921,36 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{ ID: "user-rp-claims", Traits: map[string]any{ - "email": "rp-user@example.com", - "name": "RP User", + "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) + } + } +} diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index 16e5e76c..670980b4 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -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 { diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index b528ad11..29caeeb9 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -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 { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2afd1b12..1863ac78 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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 parsed, nil } - return trimmed, nil + parsed, err := time.Parse("2006-01-02", trimmed) + if err != nil { + return nil, errors.New("date value must use unix seconds or YYYY-MM-DD") + } + return parsed.Unix(), nil case "datetime": if trimmed == "" { return nil, errors.New("datetime value is required") } - if _, err := time.Parse(time.RFC3339, trimmed); err == nil { - return trimmed, nil + if isIntegerClaimLiteral(trimmed) { + parsed, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return nil, errors.New("datetime value must use unix seconds, RFC3339, or YYYY-MM-DDTHH:mm") + } + return parsed, nil } - if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil { - return trimmed, nil + if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil { + return parsed.Unix(), nil } - return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm") + if parsed, err := time.ParseInLocation("2006-01-02T15:04", trimmed, time.UTC); err == nil { + return parsed.Unix(), nil + } + return nil, errors.New("datetime value must use unix seconds, RFC3339, or YYYY-MM-DDTHH:mm") default: return nil, fmt.Errorf("unsupported claim value type: %s", valueType) } @@ -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 { diff --git a/backend/internal/handler/dev_handler_rp_metadata_test.go b/backend/internal/handler/dev_handler_rp_metadata_test.go index 5d2a39c6..669e4f80 100644 --- a/backend/internal/handler/dev_handler_rp_metadata_test.go +++ b/backend/internal/handler/dev_handler_rp_metadata_test.go @@ -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) +} diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 5e99893f..d750e9fd 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -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 diff --git a/backend/internal/handler/rp_claims_e2e_test.go b/backend/internal/handler/rp_claims_e2e_test.go index 75d383e5..6c0a1f52 100644 --- a/backend/internal/handler/rp_claims_e2e_test.go +++ b/backend/internal/handler/rp_claims_e2e_test.go @@ -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() diff --git a/backend/internal/handler/tenant_assignment_policy.go b/backend/internal/handler/tenant_assignment_policy.go index ba8efff1..fa381207 100644 --- a/backend/internal/handler/tenant_assignment_policy.go +++ b/backend/internal/handler/tenant_assignment_policy.go @@ -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) { diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 9b89f5ba..a3dd2824 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -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 | 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,23 +1060,44 @@ function ClientConsentsPage() { aria-label={`${row.key} ${row.valueType}`} /> ) : ( - - updateMetadataDraftRow(row.id, { - value: event.target.value, - }) - } - className="font-mono text-xs" - placeholder={t( - "ui.dev.clients.consents.rp_claims.value_placeholder", - "claim value", +
+ + updateMetadataDraftRow(row.id, { + value: event.target.value, + }) + } + className="font-mono text-xs" + placeholder={t( + "ui.dev.clients.consents.rp_claims.value_placeholder", + "claim value", + )} + aria-label={`${row.key} ${row.valueType}`} + /> + {(row.valueType === "date" || + row.valueType === "datetime") && ( + )} - aria-label={`${row.key} ${row.valueType}`} - /> +
)} ) : ( - + + updateIdTokenClaim( + claim.id, + "value", + e.target.value, + ) + } + className="h-9 font-mono text-xs" + placeholder={t( + "ui.dev.clients.general.id_token_claims.value_placeholder", + "Enter the default value", + )} + disabled={isGeneralSettingsReadOnly} + aria-invalid={ + defaultValueError ? true : undefined + } + /> + {(claim.valueType === "date" || + claim.valueType === "datetime") && ( + )} - pattern={claimDefaultInputPattern( - claim.valueType, - )} - value={claim.value} - onChange={(e) => - updateIdTokenClaim( - claim.id, - "value", - e.target.value, - ) - } - className="h-9 font-mono text-xs" - placeholder={t( - "ui.dev.clients.general.id_token_claims.value_placeholder", - "Enter the default value", - )} - disabled={isGeneralSettingsReadOnly} - aria-invalid={ - defaultValueError ? true : undefined - } - /> + )} {defaultValueError && (

diff --git a/devfront/src/features/clients/rpClaimDateTime.test.ts b/devfront/src/features/clients/rpClaimDateTime.test.ts new file mode 100644 index 00000000..26d52e02 --- /dev/null +++ b/devfront/src/features/clients/rpClaimDateTime.test.ts @@ -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"); + }); +}); diff --git a/devfront/src/features/clients/rpClaimDateTime.ts b/devfront/src/features/clients/rpClaimDateTime.ts new file mode 100644 index 00000000..a60a5fea --- /dev/null +++ b/devfront/src/features/clients/rpClaimDateTime.ts @@ -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); +} diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx index 20e6746e..27e82dcb 100644 --- a/devfront/src/features/coverage/pageSmoke.test.tsx +++ b/devfront/src/features/coverage/pageSmoke.test.tsx @@ -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(, { path: "/clients/:id/consents", diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts index 0b560881..07ad54f6 100644 --- a/devfront/tests/devfront-consents.spec.ts +++ b/devfront/tests/devfront-consents.spec.ts @@ -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); diff --git a/docs/custom-field-jsonb-index-policy.md b/docs/custom-field-jsonb-index-policy.md index ae2f7a0c..b2bc9e72 100644 --- a/docs/custom-field-jsonb-index-policy.md +++ b/docs/custom-field-jsonb-index-policy.md @@ -105,14 +105,6 @@ Tenant/RP 단위로 묶어서 전달한다. "employeeNo": "E1001" } } - ], - "rp_profiles": [ - { - "client_id": "sample-rp", - "fields": { - "approvalLevel": "A" - } - } ] } ``` diff --git a/docs/devfront_auth_flow_explanation.md b/docs/devfront_auth_flow_explanation.md index 12e58ed8..4c5b383d 100644 --- a/docs/devfront_auth_flow_explanation.md +++ b/docs/devfront_auth_flow_explanation.md @@ -64,12 +64,12 @@ sequenceDiagram ### 자동 등록된 `devfront` 명세 ```bash -hydra clients create - --endpoint http://hydra:4445 - --id devfront - --grant-types authorization_code,refresh_token - --response-types code - --scope openid,offline_access,profile,email +hydra clients create + --endpoint http://hydra:4445 + --id devfront + --grant-types authorization_code,refresh_token + --response-types code + --scope openid,profile,email --token-endpoint-auth-method none \ # Public Client (PKCE 사용) --callbacks http://localhost:5174/auth/callback; ``` diff --git a/userfront-e2e/tests/session-cross-browser-debug.spec.ts b/userfront-e2e/tests/session-cross-browser-debug.spec.ts index fbab56bc..6e438ce0 100644 --- a/userfront-e2e/tests/session-cross-browser-debug.spec.ts +++ b/userfront-e2e/tests/session-cross-browser-debug.spec.ts @@ -134,7 +134,7 @@ async function loginAdminFront(context: BrowserContext): Promise { 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", diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 3e53d2de..fcc1f86e 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -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." - diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index bd48e9d8..725b4862 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -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 = "활성화된 세션만 보려면 토글을 켜주세요." - diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 09b991d2..2ddc2d11 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -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 = "" - diff --git a/userfront/lib/features/auth/domain/consent_scope_policy.dart b/userfront/lib/features/auth/domain/consent_scope_policy.dart new file mode 100644 index 00000000..3bfa1e60 --- /dev/null +++ b/userfront/lib/features/auth/domain/consent_scope_policy.dart @@ -0,0 +1,11 @@ +bool isRefreshTokenScopeAlias(String scope) { + final normalized = scope.trim().toLowerCase(); + return normalized == 'offline' || normalized == 'offline_access'; +} + +List filterConsentScopes(Iterable scopes) { + return scopes + .map((scope) => scope.trim()) + .where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope)) + .toList(growable: false); +} diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 7e7d44cd..989306f7 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -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 { '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 { } String _scopeDisplayLabel(String scope) { - if (scope == 'offline_access') { - return 'offline access'; - } return scope.replaceAll('_', ' '); } @@ -138,9 +132,11 @@ class _ConsentScreenState extends State { } // 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택 - final requestedScopes = - (info['requested_scope'] as List?)?.cast() ?? []; + final requestedScopes = filterConsentScopes( + (info['requested_scope'] as List?)?.cast() ?? [], + ); _selectedScopes.addAll(requestedScopes); + info['requested_scope'] = requestedScopes; setState(() { _consentInfo = info; @@ -299,9 +295,10 @@ class _ConsentScreenState extends State { ? clientId : tr('msg.userfront.consent.client_unknown')); final clientLogo = _consentInfo?['client']?['logo_uri']; - final requestedScopes = - (_consentInfo?['requested_scope'] as List?)?.cast() ?? - []; + final requestedScopes = filterConsentScopes( + (_consentInfo?['requested_scope'] as List?)?.cast() ?? + [], + ); return SingleChildScrollView( child: Container( diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 7a72610d..223bc654 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -597,7 +597,6 @@ const Map 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 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": diff --git a/userfront/test/consent_scope_policy_test.dart b/userfront/test/consent_scope_policy_test.dart new file mode 100644 index 00000000..909b60c1 --- /dev/null +++ b/userfront/test/consent_scope_policy_test.dart @@ -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); + }); + }); +}