From 0d60e8dd688104b8a4c4a717c0f77658c8bce913 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 1 Apr 2026 17:15:22 -0400 Subject: [PATCH] Canonicalize scopes for store auth --- .../cli/src/cli/services/store/auth.test.ts | 96 +++++++++++++++++++ packages/cli/src/cli/services/store/auth.ts | 19 +++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 33f370a57b9..1c754ae900c 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -378,6 +378,69 @@ describe('store auth service', () => { expect(setStoredStoreAppSession).not.toHaveBeenCalled() }) + test('authenticateStoreWithApp accepts compressed write scopes that imply requested read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + + test('authenticateStoreWithApp still rejects when other requested scopes are missing', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products,read_orders', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ), + ).rejects.toThrow('Shopify granted fewer scopes than were requested.') + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() @@ -409,4 +472,37 @@ describe('store auth service', () => { }), ) }) + + test('authenticateStoreWithApp accepts compressed unauthenticated write scopes that imply requested unauthenticated read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'unauthenticated_read_product_listings,unauthenticated_write_product_listings', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'unauthenticated_write_product_listings', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['unauthenticated_write_product_listings'], + }), + ) + }) }) diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 9e5a33cf80c..28853de1c9b 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -80,6 +80,19 @@ export function parseStoreAuthScopes(input: string): string[] { return [...new Set(scopes)] } +function expandImpliedStoreScopes(scopes: string[]): Set { + const expandedScopes = new Set(scopes) + + for (const scope of scopes) { + const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) + if (matches) { + expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) + } + } + + return expandedScopes +} + function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { if (!tokenResponse.scope) { outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) @@ -87,7 +100,8 @@ function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes } const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) - const missingScopes = requestedScopes.filter((scope) => !grantedScopes.includes(scope)) + const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) + const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) if (missingScopes.length > 0) { throw new AbortError( @@ -419,6 +433,9 @@ export async function authenticateStoreWithApp( userId, accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, + // Store the raw scopes returned by Shopify. Validation may treat implied + // write_* -> read_* permissions as satisfied, so callers should not assume + // session.scopes is an expanded/effective permission set. scopes: resolveGrantedScopes(tokenResponse, scopes), acquiredAt: new Date(now).toISOString(), expiresAt,