Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/cli/src/cli/services/store/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()
Expand Down Expand Up @@ -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'],
}),
)
})
})
19 changes: 18 additions & 1 deletion packages/cli/src/cli/services/store/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,28 @@ export function parseStoreAuthScopes(input: string): string[] {
return [...new Set(scopes)]
}

function expandImpliedStoreScopes(scopes: string[]): Set<string> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth adding a code comment for context on why we need this

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`)
return 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(
Expand Down Expand Up @@ -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,
Expand Down
Loading