1
0
forked from baron/baron-sso

Merge pull request 'feature/df-scope-relationships' (#1239) from feature/df-scope-relationships into dev

Reviewed-on: baron/baron-sso#1239
This commit is contained in:
2026-06-19 11:27:15 +09:00
22 changed files with 1429 additions and 1447 deletions

View File

@@ -1040,7 +1040,7 @@ func normalizePhoneForLoginID(phone string) string {
return domain.NormalizePhoneNumber(phone)
}
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string, profileStatus string) map[string]any {
claims := map[string]any{}
if traits == nil {
return claims
@@ -1089,28 +1089,27 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
if _, ok := scopeSet["profile"]; ok {
profile := map[string]any{}
names := map[string]any{}
for _, key := range []string{
"name",
"displayname",
"preferred_username",
"given_name",
"family_name",
"middle_name",
"nickname",
} {
if value := getString(key); value != "" {
names[key] = value
displayProfileName := displayName
if displayProfileName != "" {
profile["name"] = displayProfileName
}
if primaryEmail != "" {
profile["email"] = primaryEmail
}
if secondaryEmails := collectStringValues(traits, "sub_email", "secondary_emails", "additional_emails", "aliasEmails", "worksmobileAliasEmails"); len(secondaryEmails) > 0 {
profile["secondary_emails"] = secondaryEmails
}
if phone := getString("phone_number"); phone != "" {
profile["phones"] = []string{phone}
}
if employeeID := getString("employee_id"); employeeID != "" {
profile["employee_id"] = employeeID
}
if trimmedStatus := strings.TrimSpace(profileStatus); trimmedStatus != "" {
if normalizedStatus := strings.TrimSpace(domain.NormalizeUserStatus(trimmedStatus)); normalizedStatus != "" {
profile["status"] = normalizedStatus
}
}
if len(names) > 0 {
profile["names"] = names
}
emails := collectEmailList(traits, primaryEmail)
if len(emails) > 0 {
profile["emails"] = emails
}
if len(profile) > 0 {
claims["profile"] = profile
}
@@ -1215,13 +1214,63 @@ func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[
return claims
}
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string, profileStatus string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID, profileStatus)
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
claims = withRefreshTokenExpiryClaim(claims, time.Now())
return withOidcSessionMetadata(claims, sessionID)
}
func collectStringValues(traits map[string]any, keys ...string) []string {
values := make([]string, 0)
seen := make(map[string]struct{})
add := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
values = append(values, value)
}
for _, key := range keys {
raw, ok := traits[key]
if !ok || raw == nil {
continue
}
switch value := raw.(type) {
case string:
add(value)
case []string:
for _, item := range value {
add(item)
}
case []any:
for _, item := range value {
add(fmt.Sprint(item))
}
}
}
return values
}
func (h *AuthHandler) resolveProfileStatus(ctx context.Context, subject string) string {
if h == nil || h.UserRepo == nil {
return ""
}
subject = strings.TrimSpace(subject)
if subject == "" {
return ""
}
user, err := h.UserRepo.FindByID(ctx, subject)
if err != nil || user == nil {
return ""
}
return domain.NormalizeUserStatus(user.Status)
}
func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any {
if baseClaims == nil {
baseClaims = map[string]any{}
@@ -1364,7 +1413,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m
func tenantClaimScopeRequested(scopes []string) bool {
for _, scope := range scopes {
if strings.EqualFold(strings.TrimSpace(scope), "tenant") {
if strings.EqualFold(strings.TrimSpace(scope), "tenants") || strings.EqualFold(strings.TrimSpace(scope), "tenant") {
return true
}
}
@@ -6295,6 +6344,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
consentRequest.RequestedScope,
representativeTenantIDFromTraits(identity.Traits),
currentSessionID,
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
@@ -6333,6 +6383,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
consentRequest.RequestedScope,
representativeTenantIDFromTraits(identity.Traits),
currentSessionID,
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
@@ -6524,6 +6575,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
consentRequest.RequestedScope,
representativeTenantIDFromTraits(identity.Traits),
currentSessionID,
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)

View File

@@ -203,7 +203,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
"allowed_tenants": []string{"tenant-allow"},
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "tenants", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
@@ -262,9 +262,9 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
assert.Equal(t, []any{"openid", "tenants", "profile"}, body["requested_scope"])
scopeDetails := body["scope_details"].(map[string]any)
tenantDetail := scopeDetails["tenant"].(map[string]any)
tenantDetail := scopeDetails["tenants"].(map[string]any)
assert.Equal(t, true, tenantDetail["mandatory"])
}
@@ -448,7 +448,7 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
"allowed_tenants": []string{"tenant-abc"},
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "tenants", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
@@ -511,5 +511,5 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
assert.Equal(t, []string{"openid", "tenants", "profile"}, capturedGrantScopes)
}

View File

@@ -70,9 +70,12 @@ func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) {
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
traits := map[string]any{
"email": "user@baron.com",
"name": "홍길동",
"tenant_id": "primary-tenant-999", // Added primary tenant
"email": "user@baron.com",
"name": "홍길동",
"phone_number": "+821012345678",
"employee_id": "EMP-001",
"sub_email": []any{"alias1@baron.com", "alias2@baron.com"},
"tenant_id": "primary-tenant-999", // Added primary tenant
"tenant-1": map[string]any{
"department": "개발팀",
"grade": "선임",
@@ -85,12 +88,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
scopes := []string{"openid", "profile"}
t.Run("No tenantID", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "")
claims := buildOidcClaimsFromTraits(traits, scopes, "", "leave_of_absence")
assert.Equal(t, "user@baron.com", claims["email"])
assert.Equal(t, "홍길동", claims["name"])
assert.Equal(t, "primary-tenant-999", claims["tenant_id"])
assert.Nil(t, claims["department"])
assert.Nil(t, claims["grade"])
profile := claims["profile"].(map[string]any)
assert.Equal(t, "홍길동", profile["name"])
assert.Equal(t, "user@baron.com", profile["email"])
assert.Equal(t, "EMP-001", profile["employee_id"])
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
assert.Equal(t, "temporary_leave", profile["status"])
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
assert.Nil(t, claims["tenants"])
assert.Contains(t, claims["joined_tenants"], "tenant-1")
@@ -99,12 +109,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
})
t.Run("With tenant-1", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1", "leave_of_absence")
assert.Equal(t, "user@baron.com", claims["email"])
assert.Equal(t, "홍길동", claims["name"])
assert.Equal(t, "tenant-1", claims["tenant_id"])
assert.Nil(t, claims["department"])
assert.Nil(t, claims["grade"])
profile := claims["profile"].(map[string]any)
assert.Equal(t, "홍길동", profile["name"])
assert.Equal(t, "user@baron.com", profile["email"])
assert.Equal(t, "EMP-001", profile["employee_id"])
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
assert.Equal(t, "temporary_leave", profile["status"])
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
assert.Nil(t, claims["tenants"])
assert.Contains(t, claims["joined_tenants"], "tenant-1")
@@ -113,35 +130,56 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
})
t.Run("With tenant-2", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2", "leave_of_absence")
assert.Equal(t, "user@baron.com", claims["email"])
assert.Equal(t, "홍길동", claims["name"])
assert.Equal(t, "tenant-2", claims["tenant_id"])
assert.Nil(t, claims["department"])
assert.Nil(t, claims["grade"])
profile := claims["profile"].(map[string]any)
assert.Equal(t, "홍길동", profile["name"])
assert.Equal(t, "user@baron.com", profile["email"])
assert.Equal(t, "EMP-001", profile["employee_id"])
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
assert.Equal(t, "temporary_leave", profile["status"])
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
assert.Nil(t, claims["tenants"])
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
})
t.Run("With non-existent tenant", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3", "leave_of_absence")
assert.Equal(t, "user@baron.com", claims["email"])
assert.Equal(t, "홍길동", claims["name"])
assert.Equal(t, "tenant-3", claims["tenant_id"])
assert.Nil(t, claims["department"])
assert.Nil(t, claims["grade"])
profile := claims["profile"].(map[string]any)
assert.Equal(t, "홍길동", profile["name"])
assert.Equal(t, "user@baron.com", profile["email"])
assert.Equal(t, "EMP-001", profile["employee_id"])
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
assert.Equal(t, "temporary_leave", profile["status"])
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
assert.Nil(t, claims["tenants"])
assert.Contains(t, claims["joined_tenants"], "tenant-1")
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
})
t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1")
t.Run("Tenants scope includes detailed tenant metadata", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1", "leave_of_absence")
assert.Equal(t, "tenant-1", claims["tenant_id"])
assert.Equal(t, "개발팀", claims["department"])
assert.Equal(t, "선임", claims["grade"])
profile := claims["profile"].(map[string]any)
assert.Equal(t, "홍길동", profile["name"])
assert.Equal(t, "user@baron.com", profile["email"])
assert.Equal(t, "EMP-001", profile["employee_id"])
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
assert.Equal(t, "temporary_leave", profile["status"])
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
assert.NotNil(t, claims["tenants"])
assert.Contains(t, claims["joined_tenants"], "tenant-1")
assert.Contains(t, claims["joined_tenants"], "tenant-2")
@@ -190,7 +228,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-dynamic",
"requested_scope": []string{"openid", "profile", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-123",
"client": map[string]any{
"client_id": "client-app",
@@ -260,7 +298,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-dynamic",
"grant_scope": []string{"openid", "profile", "tenant"},
"grant_scope": []string{"openid", "profile", "tenants"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -290,7 +328,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-representative-tenant",
"requested_scope": []string{"openid", "profile", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-representative",
"client": map[string]any{
"client_id": "client-app",
@@ -367,7 +405,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-hanmac-tenant-claim",
"requested_scope": []string{"openid", "profile", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-hanmac",
"client": map[string]any{
"client_id": "hanmac-rp",
@@ -462,7 +500,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-hanmac-tenant-claim",
"grant_scope": []string{"openid", "profile", "tenant"},
"grant_scope": []string{"openid", "profile", "tenants"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -574,7 +612,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-rp-profile",
"requested_scope": []string{"openid", "profile", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-123",
"client": map[string]any{
"client_id": "client-app",
@@ -666,7 +704,7 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-skip-dynamic",
"requested_scope": []string{"openid", "profile", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"skip": true,
"subject": "user-456",
"client": map[string]any{
@@ -845,7 +883,7 @@ func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
"writePermission": "admin_only",
},
},
}, []string{"openid", "profile", "email"}, "")
}, []string{"openid", "profile", "email"}, "", "")
assert.Equal(t, "2026-06-09", claims["contract_date"])
assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"])
@@ -861,7 +899,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", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-789",
"client": map[string]any{
"client_id": "client-configured-claims",
@@ -973,7 +1011,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", "tenant"},
"requested_scope": []string{"openid", "profile", "tenants"},
"subject": "user-rp-claims",
"client": map[string]any{
"client_id": "client-rp-claims",
@@ -1119,7 +1157,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-user-claims",
"grant_scope": []string{"openid", "profile", "tenant"},
"grant_scope": []string{"openid", "profile", "tenants"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")

View File

@@ -463,7 +463,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
out := make([]string, 0, len(combined))
appendIfPresent := func(scope string) {
scope = strings.TrimSpace(scope)
scope = canonicalConsentScopeName(scope)
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
return
}
@@ -471,7 +471,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
return
}
for _, candidate := range combined {
if strings.TrimSpace(candidate) != scope {
if canonicalConsentScopeName(candidate) != scope {
continue
}
seen[scope] = struct{}{}
@@ -481,10 +481,10 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
}
appendIfPresent("openid")
appendIfPresent("tenant")
appendIfPresent("tenants")
for _, scope := range combined {
scope = strings.TrimSpace(scope)
scope = canonicalConsentScopeName(scope)
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
@@ -501,7 +501,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
func requiredClientScopes(client domain.HydraClient) []string {
required := make([]string, 0, 4)
if clientTenantAccessRestricted(client.Metadata) {
required = append(required, "tenant")
required = append(required, "tenants")
}
if client.Metadata == nil {
@@ -535,3 +535,12 @@ func requiredClientScopes(client domain.HydraClient) []string {
return normalizeScopesInConsentOrder(required)
}
func canonicalConsentScopeName(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "tenant":
return "tenants"
default:
return strings.TrimSpace(scope)
}
}

View File

@@ -121,20 +121,20 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) {
assert.False(t, hydraCalled)
}
func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) {
func TestMergeRequestedScopesWithClientRequirements_AddsTenantsScope(t *testing.T) {
client := domain.HydraClient{
Metadata: map[string]any{
"tenant_access_restricted": true,
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "tenants", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
}
merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"})
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
assert.Equal(t, []string{"openid", "tenants", "profile"}, merged)
}
func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) {
@@ -154,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
[]string{"openid", "offline", "profile", "offline_access"},
)
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
assert.Equal(t, []string{"openid", "tenants", "profile", "offline_access", "email"}, merged)
}
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {

View File

@@ -864,7 +864,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email tenant",
"scope": "openid profile email tenants",
"token_endpoint_auth_method": "private_key_jwt",
"metadata": map[string]any{
"status": "active",
@@ -905,7 +905,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"name": "App One Updated",
"scopes": []string{"openid", "profile", "email", "tenant"},
"scopes": []string{"openid", "profile", "email", "tenants"},
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-1", "tenant-2"},
@@ -3009,7 +3009,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.
RedirectURIs: []string{"https://rp.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scope: "openid tenant profile email",
Scope: "openid tenants profile email",
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"tenant_access_restricted": true,
@@ -3093,7 +3093,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.
RedirectURIs: []string{"https://rp.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scope: "openid tenant profile email",
Scope: "openid tenants profile email",
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"tenant_access_restricted": true,

View File

@@ -29,6 +29,23 @@ function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject {
return result;
}
function setTomlValue(
target: TomlObject,
path: string[],
value: TomlValue,
): void {
let cursor: TomlObject = target;
for (let index = 0; index < path.length - 1; index += 1) {
const key = path[index];
const current = cursor[key];
if (!current || typeof current === "string") {
cursor[key] = {};
}
cursor = cursor[key] as TomlObject;
}
cursor[path[path.length - 1]] = value;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
@@ -82,7 +99,7 @@ function parseToml(raw: string): TomlObject {
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
setTomlValue(cursor, key.split("."), value);
}
return root;

View File

@@ -91,7 +91,7 @@ function makeClientDetail(
if (includeTenantScope) {
structuredScopes.push({
id: "2",
name: "tenant",
name: "tenants",
description: "Tenant access",
mandatory: tenantScopeMandatory,
locked: tenantAccessRestricted,
@@ -106,7 +106,7 @@ function makeClientDetail(
status: "active",
redirectUris: ["https://rp.example.com/callback"],
scopes: includeTenantScope
? ["openid", "tenant", "profile"]
? ["openid", "tenants", "profile"]
: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
@@ -334,7 +334,48 @@ describe("ClientGeneralPage RP claims", () => {
);
});
it("preserves tenant scope mandatory state when tenant access restriction is off", async () => {
it("clears saved RP claims when custom claims are disabled", async () => {
const { container } = await renderPage();
const claimToggle = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
).find((button) =>
(button.getAttribute("aria-label") ?? "").includes("커스텀 클레임 사용"),
);
expect(claimToggle).toBeDefined();
expect(claimToggle?.getAttribute("aria-checked")).toBe("true");
await act(async () => {
claimToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(claimToggle?.getAttribute("aria-checked")).toBe("false");
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_enabled: false,
id_token_claims: [],
}),
}),
);
});
it("preserves tenants scope mandatory state when tenant access restriction is off", async () => {
fetchClientMock.mockResolvedValue(
makeClientDetail("old_claim", {
includeTenantScope: true,
@@ -355,7 +396,7 @@ describe("ClientGeneralPage RP claims", () => {
const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find(
(row) =>
Array.from(row.querySelectorAll("input")).some(
(input) => (input as HTMLInputElement).value === "tenant",
(input) => (input as HTMLInputElement).value === "tenants",
),
);
@@ -393,7 +434,7 @@ describe("ClientGeneralPage RP claims", () => {
tenant_access_restricted: false,
structured_scopes: expect.arrayContaining([
expect.objectContaining({
name: "tenant",
name: "tenants",
mandatory: false,
locked: false,
}),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import type * as React from "react";
import { cn } from "../../../lib/utils";
interface SettingsTableShellProps {
className?: string;
bodyClassName?: string;
children: React.ReactNode;
}
function SettingsTableShell({
className,
bodyClassName,
children,
}: SettingsTableShellProps) {
return (
<div
className={cn(
"overflow-hidden rounded-md border border-border bg-background",
className,
)}
>
<div className={cn("overflow-auto", bodyClassName)}>{children}</div>
</div>
);
}
function SettingsTable({
className,
...props
}: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn("w-full text-sm", className)} {...props} />;
}
function SettingsTableHeader({
className,
...props
}: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead
className={cn(
"bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground",
className,
)}
{...props}
/>
);
}
function SettingsTableBody({
className,
...props
}: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={cn("divide-y divide-border", className)} {...props} />
);
}
function SettingsTableRow({
className,
...props
}: React.HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn(
"border-b transition-colors hover:bg-muted/20 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function SettingsTableHead({
className,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-12 px-4 text-left text-xs font-bold uppercase tracking-wider text-black align-middle dark:text-foreground",
className,
)}
{...props}
/>
);
}
function SettingsTableCell({
className,
...props
}: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-4 py-3 align-top", className)} {...props} />;
}
interface SettingsTableEmptyStateProps {
colSpan: number;
children: React.ReactNode;
className?: string;
}
function SettingsTableEmptyState({
colSpan,
children,
className,
}: SettingsTableEmptyStateProps) {
return (
<tr>
<td
colSpan={colSpan}
className={cn(
"px-4 py-8 text-center text-sm text-muted-foreground",
className,
)}
>
{children}
</td>
</tr>
);
}
export {
SettingsTable,
SettingsTableBody,
SettingsTableCell,
SettingsTableEmptyState,
SettingsTableHead,
SettingsTableHeader,
SettingsTableRow,
SettingsTableShell,
};

View File

@@ -6,6 +6,32 @@ afterEach(() => {
});
describe("i18n", () => {
it("returns Korean copy for dotted developer claim headers", () => {
window.localStorage.setItem("locale", "ko");
expect(
t("ui.dev.clients.general.id_token_claims.table.key", "Claim Key"),
).toBe("클레임 키");
expect(
t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
),
).toBe("값 유형");
expect(
t(
"msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
),
).toBe("사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.");
expect(
t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
),
).toBe("설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.");
});
it("returns English copy for the developer request and grants screens", () => {
window.localStorage.setItem("locale", "en");
@@ -32,5 +58,27 @@ describe("i18n", () => {
"현재 부여된 개발자 권한 목록입니다.",
),
).toBe("Current developer access grants.");
expect(
t(
"msg.dev.clients.general.id_token_claims.subtitle",
"RP 전용 확장 claim을 구분해서 관리합니다.",
),
).toBe(
"User-specific claim values are edited in the Consent and Claims tabs.",
);
expect(
t(
"msg.dev.clients.general.id_token_claims.hint",
"사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
),
).toBe(
"User-specific claim values are edited in the Consent and Claims tabs.",
);
expect(
t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.",
),
).toBe("Preview the claim set that will be saved with these settings.");
});
});

View File

@@ -515,9 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
[msg.dev.clients.scopes]
email = "Email"
openid = "Openid"
profile = "Profile"
email = "User email information"
openid = "Base scope required for OIDC login"
profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status"
tenant = "Tenant access"
tenants = "Tenant root/parent chain and joined_tenants access"
[msg.dev.dashboard]
access_denied = "The dashboard is available only to users with developer access."
@@ -1611,6 +1613,7 @@ session_required_info = "Show SID Claim Required help"
add = "Scope Add"
description_placeholder = "Description Placeholder"
name_placeholder = "e.g. profile"
subtitle = "Review the permissions this client can request."
title = "Scopes"
offline_access_title = "offline_access scope is required when using refresh tokens."
offline_access_toggle = "Show details"
@@ -1622,7 +1625,7 @@ description = "Scope Description"
mandatory = "Mandatory"
name = "Scope Name"
delete = "Delete"
tenant = "Tenant"
tenants = "Tenants"
[ui.dev.clients.general.tenant_access]
title = "Tenant access restriction"
@@ -1633,7 +1636,7 @@ search_placeholder = "Search by tenant name or slug"
selected_title = "Allowed tenants"
selected_empty = "No tenants selected yet."
empty = "No tenants match your search."
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
hint = "Turning this on adds the tenants scope automatically and requires at least one allowed tenant."
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
picker_title = "Select tenant"
@@ -1646,6 +1649,7 @@ picker_hint_with_count = "{{count}} tenants selected."
[ui.dev.clients.general.id_token_claims]
title = "Custom Claims"
add = "Add Claim"
enabled = "Custom Claims Enabled"
preview_title = "Saved JSON Preview"
namespace_label = "Claim namespace"
namespace_top_level = "top-level"
@@ -1671,6 +1675,11 @@ value_type_object = "Object"
key_placeholder = "e.g. locale"
value_placeholder = "Enter the default value"
[msg.dev.clients.general.id_token_claims]
subtitle = "User-specific claim values are edited in the Consent and Claims tabs."
hint = "User-specific claim values are edited in the Consent and Claims tabs."
preview_hint = "Preview the claim set that will be saved with these settings."
[ui.dev.clients.general.security]
private = "Server Side App"
pkce = "PKCE"
@@ -1795,9 +1804,9 @@ subtitle = "Applications"
title = "RP registry"
[ui.dev.clients.scopes]
email = "Email"
email = "User email information"
openid = "Openid"
profile = "Profile"
profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status"
[ui.dev.clients.table]
actions = "Actions"

View File

@@ -463,8 +463,8 @@ offline_access_condition_grant_type = "client grant_types에 refresh_token 포
[msg.dev.clients.general.id_token_claims]
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims조를 미리 확인할 수 있습니다."
hint = "사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "설정 저장 시 반영될 claim 구성을 미리 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
@@ -515,9 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
[msg.dev.clients.scopes]
email = "이메일 주소 접근"
openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
email = "사용자 이메일 정보"
openid = "OIDC 로그인에 필요한 기본 scope"
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
tenant = "테넌트 접근"
tenants = "소속 테넌트 정보 접근"
[msg.dev.dashboard]
access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
@@ -1610,6 +1612,7 @@ session_required_info = "SID Claim Required 설명 보기"
add = "스코프 추가"
description_placeholder = "권한에 대한 설명"
name_placeholder = "e.g. profile"
subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
title = "스코프"
offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다."
offline_access_toggle = "상세 안내 보기"
@@ -1621,7 +1624,7 @@ description = "설명"
mandatory = "필수"
name = "스코프 이름"
delete = "삭제"
tenant = "테넌트"
tenants = "테넌트"
[ui.dev.clients.general.tenant_access]
title = "테넌트 접근 제한"
@@ -1632,7 +1635,7 @@ search_placeholder = "테넌트 이름 또는 슬러그로 검색"
selected_title = "허용 테넌트"
selected_empty = "아직 선택된 테넌트가 없습니다."
empty = "검색 결과가 없습니다."
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
hint = "제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
picker_title = "테넌트 선택"
@@ -1645,6 +1648,7 @@ picker_hint_with_count = "현재 {{count}}개가 선택되어 있습니다."
[ui.dev.clients.general.id_token_claims]
title = "커스텀 클레임"
add = "Claim 추가"
enabled = "커스텀 클레임 사용"
preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
@@ -1652,10 +1656,10 @@ namespace_rp_claims = "rp_claims"
nullable_label = "Nullable"
read_user_allowed_label = "사용자 읽기 허용"
write_user_allowed_label = "사용자 쓰기 허용"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Nullable"
table.key = "클레임 키"
table.namespace = "네임스페이스"
table.value_type = "값 유형"
table.nullable = "Null 허용"
table.read_user_allowed = "사용자 읽기"
table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값"
@@ -1794,9 +1798,9 @@ subtitle = "연동 앱"
title = "RP registry"
[ui.dev.clients.scopes]
email = "이메일 주소 접근"
email = "사용자 이메일 정보"
openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
[ui.dev.clients.table]
actions = "액션"

View File

@@ -556,6 +556,8 @@ description = ""
email = ""
openid = ""
profile = ""
tenant = ""
tenants = ""
[msg.dev.dashboard]
access_denied = ""
@@ -1660,6 +1662,7 @@ session_required_info = ""
add = ""
description_placeholder = ""
name_placeholder = ""
subtitle = ""
title = ""
offline_access_title = ""
offline_access_toggle = ""
@@ -1695,6 +1698,7 @@ picker_hint_with_count = ""
[ui.dev.clients.general.id_token_claims]
title = ""
add = ""
enabled = ""
preview_title = ""
namespace_label = ""
namespace_top_level = ""
@@ -1720,6 +1724,11 @@ value_type_object = ""
key_placeholder = ""
value_placeholder = ""
[msg.dev.clients.general.id_token_claims]
subtitle = ""
hint = ""
preview_hint = ""
[ui.dev.clients.general.security]
private = ""
pkce = ""

View File

@@ -143,6 +143,7 @@ test.describe("DevFront clients lifecycle", () => {
await installDevApiMock(page, state);
await page.goto("/clients/client-claims/settings");
await page.getByLabel(/커스텀 클레임 사용|Custom Claims/i).click();
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await expect(page.getByText("rp_claims").first()).toBeVisible();
await expect(

View File

@@ -0,0 +1,176 @@
# Back-Channel Logout 통합 가이드
이 문서는 Baron SSO와 연동하는 RP들이 `Back-Channel Logout`을 어떻게 처리하는지 한 곳에서 정리합니다.
핵심은 다음 두 가지입니다.
1. Baron SSO가 RP로 보내는 요청 형식은 공통입니다.
2. RP가 그 요청을 받아 세션을 찾고 지우는 내부 로직은 RP 유형에 따라 달라질 수 있습니다.
## 결론
Baron SSO는 모든 RP에 대해 같은 방식으로 `POST /backchannel-logout`를 보냅니다.
- `Content-Type: application/x-www-form-urlencoded`
- body: `logout_token=<jwt>`
- 검증용 공개키는 `GET /api/v1/auth/backchannel/jwks.json`
차이는 Baron이 보낸 뒤 RP 내부에서 세션을 어디서 찾고 어떻게 파기하느냐입니다.
- `PKCE RP`
- `server-side-app RP`
- `headless login RP``PKCE` 기반 custom login UI 변형
## 공통 시퀀스
아래 흐름은 세 RP에 공통입니다.
```mermaid
sequenceDiagram
autonumber
participant Baron as Baron SSO
participant RP as RP
participant JWKS as Baron Back-Channel JWKS
participant Store as RP Session Store
Baron->>RP: POST /backchannel-logout\nlogout_token=<jwt>
RP->>RP: logout_token 추출
RP->>JWKS: JWKS로 서명 검증
JWKS-->>RP: public key
RP->>RP: iss / aud / events / nonce / jti 검증
RP->>RP: sid 또는 sub로 세션 탐색
RP->>Store: session destroy
Store-->>RP: 삭제 완료
RP->>RP: 세션 매핑 제거
RP-->>Baron: 200 OK
```
## 공통 전송 규칙
Baron SSO에서 RP로 보내는 형식은 동일합니다.
| 항목 | 값 |
| --- | --- |
| HTTP method | `POST` |
| Path | `/backchannel-logout` |
| Content-Type | `application/x-www-form-urlencoded` |
| Body | `logout_token=<jwt>` |
| 검증 JWKS | `/api/v1/auth/backchannel/jwks.json` |
전송 로직은 Baron 쪽에서 공통으로 처리됩니다.
- [`backend/internal/service/backchannel_logout_service.go`](/home/kyy/workspace/baron-sso/backend/internal/service/backchannel_logout_service.go)
- [`backend/internal/handler/auth_handler.go`](/home/kyy/workspace/baron-sso/backend/internal/handler/auth_handler.go)
## RP별 차이
세 RP는 모두 `logout_token`을 받아 검증하고 세션을 지운다는 점은 같습니다.
다만 세션이 만들어지는 시점과 저장 방식이 다릅니다.
| 항목 | PKCE RP | server-side-app RP | headless login RP |
| --- | --- | --- | --- |
| 로그인 성격 | Authorization Code + PKCE | confidential client | PKCE 기반 custom login UI |
| 백채널 수신 endpoint | 필요 | 필요 | 필요 |
| 세션 저장 구조 | 앱 서버/BFF/브라우저 연동에 따라 다양 | 서버 세션 중심 | headless 로그인 이후 로컬 세션 바인딩 |
| `sid/sub` 매핑 | callback 이후 저장 | callback 이후 저장 | login 성공 이후 저장 |
| 세션 파기 방식 | 매핑된 session id 삭제 | 매핑된 session id 삭제 | 매핑된 session id 삭제 |
| 차이의 핵심 | 서버 endpoint가 없으면 처리 불가 | 서버 세션 구조와 잘 맞음 | 로그인 진입점만 다르고 로그아웃 처리는 공통 패턴 |
## RP별 처리 설명
### PKCE RP
PKCE RP는 브라우저 기반 로그인 흐름을 사용하지만, 백채널 로그아웃을 받으려면 **반드시 서버 endpoint**가 있어야 합니다.
이유는 Baron이 브라우저가 아니라 RP 서버로 직접 `POST`를 보내기 때문입니다.
처리 순서:
1. callback 이후 `sid` 또는 `sub`를 RP 세션과 바인딩합니다.
2. Baron이 `POST /backchannel-logout`를 보냅니다.
3. RP가 `logout_token`을 검증합니다.
4. `sid` 우선, 실패 시 `sub`로 세션을 찾습니다.
5. 세션 스토어에서 해당 세션을 삭제합니다.
주의:
- 순수 frontend-only PKCE 앱은 백채널 로그아웃을 직접 받을 수 없습니다.
- 서버나 BFF가 있어야 합니다.
### server-side-app RP
server-side-app RP는 confidential client이므로, 서버 세션 구조와 백채널 로그아웃이 자연스럽게 맞습니다.
처리 순서:
1. OIDC Authorization Code 로그인과 callback을 처리합니다.
2. callback 이후 `sid` 또는 `sub`를 서버 세션과 바인딩합니다.
3. Baron이 `POST /backchannel-logout`를 보냅니다.
4. RP가 `logout_token`을 검증합니다.
5. 세션 매핑을 찾아 직접 파기합니다.
이 유형은 PKCE보다 세션 관리가 명확해서 문서화와 운영이 단순합니다.
### headless login RP
headless login은 별도의 로그아웃 타입이 아니라 PKCE 계열의 로그인 변형입니다.
즉, 로그인 시에는 custom login UI가 있고 RP backend가 headless login API를 호출하지만, 백채널 로그아웃은 결국 동일한 패턴으로 처리합니다.
처리 순서:
1. headless login 성공 후 `sid` 또는 `sub`를 RP 세션에 바인딩합니다.
2. Baron이 `POST /backchannel-logout`를 보냅니다.
3. RP가 `logout_token`을 검증합니다.
4. `sid` 또는 `sub`로 세션을 찾습니다.
5. 세션 스토어에서 해당 세션을 삭제합니다.
핵심은 로그인 진입점만 다르고, 로그아웃 처리 패턴은 PKCE RP와 같습니다.
## 공통 검증 규칙
RP는 아래 항목을 검증해야 합니다.
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
권장 사항:
- `jti` replay 방지
- 시계 오차 허용
- 검증 실패 시 `400`
## 세션 파기 규칙
`Back-Channel Logout`은 현재 브라우저 요청의 `req.session.destroy()`만으로는 부족합니다.
반드시 세션 저장소에서 실제 세션 id를 찾아 직접 파기해야 합니다.
권장 우선순위:
1. `sid`로 탐색
2. `sid`가 없거나 매칭 실패 시 `sub`로 fallback
## 공통 테스트 포인트
1. RP 로그인 후 `sid/sub -> sessionId` 매핑이 생성되는지 확인
2. Baron이 `POST /backchannel-logout`를 실제로 보내는지 확인
3. RP가 `logout_token`을 검증하는지 확인
4. 세션 스토어에서 세션이 삭제되는지 확인
5. 동일한 `logout_token` 재전송 시 replay 방지가 동작하는지 확인
## 관련 문서
- [`docs/pkce-backchannel-logout-guide.md`](/home/kyy/workspace/baron-sso/docs/pkce-backchannel-logout-guide.md)
- [`docs/server-side-app-backchannel-logout-guide.md`](/home/kyy/workspace/baron-sso/docs/server-side-app-backchannel-logout-guide.md)
## 참고 구현
- [`backend/internal/service/backchannel_logout_service.go`](/home/kyy/workspace/baron-sso/backend/internal/service/backchannel_logout_service.go)
- [`backend/internal/handler/auth_handler.go`](/home/kyy/workspace/baron-sso/backend/internal/handler/auth_handler.go)

View File

@@ -1,321 +0,0 @@
# PKCE RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.**
즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다.
1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP
2. `logout_token`을 수신하는 서버 endpoint
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP
- RP가 자체 세션 또는 BFF 세션을 보유하는 경우
- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱
이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다.
## devfront 등록 기준
PKCE RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `pkce`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: pkce
Redirect URI: https://rp.example.com/callback
Back-Channel Logout URI: https://rp.example.com/backchannel-logout
SID Claim Required: off
```
로컬 Docker 개발 예시:
```text
Redirect URI: http://localhost:3333/callback
Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다.
## 구현 요구사항
PKCE RP는 최소한 아래를 구현해야 합니다.
### 1. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 2. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 3. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 현실적일 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. PKCE RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js`
- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:3333/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음
그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다.
## 로직 흐름
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as PKCE RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code + PKCE 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: code_verifier 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```
## 권장 결론
PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오.
1. PKCE 로그인 플로우는 그대로 유지
2. logout 수신용 서버 endpoint 별도 구현
3. `sid``sub`를 모두 저장
4. 세션 스토어에서 직접 세션 파기
5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용
이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다.

View File

@@ -238,12 +238,17 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을
}
},
"profile": {
"emails": [
"hanmac-user@example.com"
"email": "hanmac-user@example.com",
"secondary_emails": [
"alias1@hanmaceng.co.kr",
"alias2@hanmaceng.co.kr"
],
"names": {
"name": "한맥 사용자"
}
"phones": [
"+821012345678"
],
"name": "한맥 사용자",
"employee_id": "EMP-001",
"status": "temporary_leave"
}
}
```

View File

@@ -1,322 +0,0 @@
# Server-Side App RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다.
즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다.
1. OIDC Authorization Code 로그인과 callback 처리
2. `logout_token`을 수신하는 `Back-Channel Logout URI`
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- `server-side-app` 타입 RP
- confidential client
- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP
- 자체 서버 세션 또는 BFF 세션을 보유하는 RP
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- public client 기반 PKCE 앱
## devfront 등록 기준
`server-side-app` RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `server-side-app`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: server-side-app
Redirect URI: http://localhost:4444/callback
Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout
SID Claim Required: off
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다.
## 구현 요구사항
`server-side-app` RP는 최소한 아래를 구현해야 합니다.
### 1. confidential client 구성
RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다.
1. `client_secret_basic`
2. `client_secret_post`
즉 token 교환 시:
- `client_id`
- `client_secret`
가 함께 사용됩니다.
PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다.
### 2. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 3. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 4. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- `sid` 중심 세션 모델을 운영하는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 유연할 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. server-side-app RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js`
- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:4444/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함
`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다.
1. `client_id`
2. `client_secret`
3. `token_endpoint_auth_method`
4. `redirect_uri`
이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다.
## 시퀀스 다이어그램
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as Server-Side RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: client_secret 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```

View File

@@ -1,11 +1,11 @@
bool isRefreshTokenScopeAlias(String scope) {
bool isOfflineScopeAlias(String scope) {
final normalized = scope.trim().toLowerCase();
return normalized == 'offline' || normalized == 'offline_access';
return normalized == 'offline';
}
List<String> filterConsentScopes(Iterable<String> scopes) {
return scopes
.map((scope) => scope.trim())
.where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope))
.where((scope) => scope.isNotEmpty && !isOfflineScopeAlias(scope))
.toList(growable: false);
}

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.12"
toml:
dependency: "direct main"
description:

View File

@@ -3,7 +3,7 @@ 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', () {
test('keeps offline_access visible and filters only offline', () {
expect(
filterConsentScopes([
'openid',
@@ -12,14 +12,14 @@ void main() {
'offline_access',
'email',
]),
['openid', 'profile', 'email'],
['openid', 'profile', 'offline_access', 'email'],
);
});
test('detects refresh token scope aliases case-insensitively', () {
expect(isRefreshTokenScopeAlias('OFFLINE'), isTrue);
expect(isRefreshTokenScopeAlias(' offline_access '), isTrue);
expect(isRefreshTokenScopeAlias('profile'), isFalse);
test('detects offline scope alias case-insensitively', () {
expect(isOfflineScopeAlias('OFFLINE'), isTrue);
expect(isOfflineScopeAlias(' offline_access '), isFalse);
expect(isOfflineScopeAlias('profile'), isFalse);
});
});
}