From ca15e2a35c714e074035a6b83e8611762ce83fcb Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 14:54:49 +0900 Subject: [PATCH] =?UTF-8?q?offline=5Faccess=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?refresh=5Ftoken=20=EB=B0=9C=EA=B8=89=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 2 +- .../internal/handler/client_tenant_access.go | 4 ++-- .../handler/client_tenant_access_test.go | 10 ++++++---- backend/internal/handler/dev_handler.go | 8 ++++---- backend/internal/handler/dev_handler_test.go | 8 ++++---- .../clients/ClientGeneralPage.claims.test.tsx | 4 ++-- .../src/features/clients/ClientGeneralPage.tsx | 18 ++++++++++++++++++ .../tests/devfront-client-claims-cache.spec.ts | 8 ++++---- 8 files changed, 41 insertions(+), 21 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e92041d3..185a6c68 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -8430,7 +8430,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [ seen := map[string]struct{}{} for _, scope := range append([]string{"openid"}, scopes...) { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index 670980b4..a5f9bbe3 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { appendIfPresent := func(scope string) { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { return } if _, ok := seen[scope]; ok { @@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { for _, scope := range combined { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index 29caeeb9..661cd631 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/gofiber/fiber/v2" @@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias []string{"openid", "offline", "profile", "offline_access"}, ) - assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged) + assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged) } func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) { @@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) parsed, err := url.Parse(urlString) assert.NoError(t, err) scopes := parsed.Query().Get("scope") + scopeItems := strings.Fields(scopes) - assert.Equal(t, "openid profile email", scopes) - assert.NotContains(t, scopes, "offline") - assert.NotContains(t, scopes, "offline_access") + assert.Equal(t, "openid profile offline_access email", scopes) + assert.NotContains(t, scopeItems, "offline") + assert.Contains(t, scopeItems, "offline_access") } func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 014031f5..eacfdd83 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -3828,7 +3828,7 @@ func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { } func defaultClientScopes() []string { - return []string{"openid", "profile", "email"} + return []string{"openid", "profile", "email", "offline_access"} } func defaultGrantTypes() []string { @@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string { seen := make(map[string]struct{}, len(scopes)) for _, scope := range scopes { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { @@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string { return normalized } -func isRefreshTokenScopeAlias(scope string) bool { +func isLegacyRefreshTokenScopeAlias(scope string) bool { switch strings.ToLower(strings.TrimSpace(scope)) { - case "offline", "offline_access": + case "offline": return true default: return false diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d750e9fd..0ababf7b 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "openid profile email", captured.Scope) + assert.Equal(t, "openid profile offline_access email", captured.Scope) assert.NotContains(t, strings.Fields(captured.Scope), "offline") - assert.NotContains(t, strings.Fields(captured.Scope), "offline_access") + assert.Contains(t, strings.Fields(captured.Scope), "offline_access") assert.Contains(t, captured.GrantTypes, "refresh_token") } @@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "openid profile email", captured.Scope) + assert.Equal(t, "openid profile offline_access email", captured.Scope) assert.NotContains(t, strings.Fields(captured.Scope), "offline") - assert.NotContains(t, strings.Fields(captured.Scope), "offline_access") + assert.Contains(t, strings.Fields(captured.Scope), "offline_access") assert.Contains(t, captured.GrantTypes, "refresh_token") } diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index a244ee31..2aa94e53 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -409,7 +409,7 @@ describe("ClientGeneralPage RP claims", () => { ); }); - it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => { + it("shows supported scopes including offline_access and custom claims from the add scope button", async () => { const { container } = await renderPage(); const addScopeButton = Array.from( @@ -422,7 +422,7 @@ describe("ClientGeneralPage RP claims", () => { }); await flush(); - expect(container.textContent).not.toContain("offline_access"); + expect(container.textContent).toContain("offline_access"); expect(container.textContent).toContain("old_claim"); const customClaimButton = Array.from( diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index af826426..710c7e5b 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -659,6 +659,15 @@ function ClientGeneralPage() { description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), mandatory: false, }, + { + id: "5", + name: "offline_access", + description: t( + "msg.dev.clients.scopes.offline_access", + "refresh token 발급 요청", + ), + mandatory: false, + }, ]); const [idTokenClaims, setIdTokenClaims] = useState([]); const browserTimeZone = useMemo(() => getBrowserTimeZone(), []); @@ -759,6 +768,15 @@ function ClientGeneralPage() { description: tenantScopeDescription, source: "standard", }, + { + id: "standard-offline-access", + name: "offline_access", + description: t( + "msg.dev.clients.scopes.offline_access", + "refresh token 발급 요청", + ), + source: "standard", + }, ], [tenantScopeDescription], ); diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index 4a21e3c3..cf5161a7 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -99,7 +99,7 @@ test.describe("DevFront RP claim cache", () => { await expect(claimKeyInput).toHaveValue("new_claim"); }); - test("adds supported scopes and custom claim keys from the scope picker without offline_access", async ({ + test("adds supported scopes and custom claim keys from the scope picker including offline_access", async ({ page, }) => { const state = { @@ -142,9 +142,9 @@ test.describe("DevFront RP claim cache", () => { .getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i }) .click(); - await expect(page.getByText("offline_access", { exact: true })).toHaveCount( - 0, - ); + await expect( + page.getByText("offline_access", { exact: true }), + ).toBeVisible(); await expect( page.getByRole("button", { name: /employee_code/ }), ).toBeVisible();