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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal file
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal 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,
|
||||
};
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "액션"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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(
|
||||
|
||||
176
docs/backchannel-logout-guide.md
Normal file
176
docs/backchannel-logout-guide.md
Normal 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)
|
||||
@@ -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 로컬 세션 종료까지 이어집니다.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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: 루트 리다이렉트 또는 비로그인 응답
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user