Skip to content

Commit 962d1f5

Browse files
authored
Merge pull request #6839 from Shopify/kd-add-to-cart-bug
Fix 401 Unauthorized on cart AJAX endpoints during `shopify theme dev`
2 parents 5b7c919 + ac00b75 commit 962d1f5

File tree

3 files changed

+135
-3
lines changed

3 files changed

+135
-3
lines changed

.changeset/fix-cart-ajax-401.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': patch
3+
---
4+
5+
Fix 401 Unauthorized errors on cart AJAX endpoints during `shopify theme dev`

packages/theme/src/cli/utilities/theme-environment/proxy.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
patchRenderingResponse,
66
proxyStorefrontRequest,
77
} from './proxy.js'
8-
import {describe, test, expect} from 'vitest'
8+
import {describe, test, expect, vi, afterEach} from 'vitest'
99
import {createEvent} from 'h3'
1010
import {IncomingMessage, ServerResponse} from 'node:http'
1111
import {Socket} from 'node:net'
@@ -401,5 +401,116 @@ describe('dev proxy', () => {
401401
'Request failed: Hostname mismatch. Expected host: cdn.shopify.com. Resulting URL hostname: evil.com',
402402
)
403403
})
404+
405+
const themeCtx = {
406+
session: {
407+
storeFqdn: 'my-store.myshopify.com',
408+
storefrontToken: 'test-sfr-token',
409+
storefrontPassword: '',
410+
sessionCookies: {},
411+
},
412+
options: {host: 'localhost', port: '9292'},
413+
localThemeFileSystem: {files: new Map()},
414+
localThemeExtensionFileSystem: {files: new Map()},
415+
type: 'theme',
416+
} as unknown as DevServerContext
417+
418+
const extensionCtx = {
419+
...themeCtx,
420+
type: 'theme-extension',
421+
} as unknown as DevServerContext
422+
423+
let fetchMock: ReturnType<typeof vi.fn>
424+
425+
afterEach(() => {
426+
vi.unstubAllGlobals()
427+
})
428+
429+
function stubFetchAndProxy(method: string, path: string, context: DevServerContext) {
430+
fetchMock = vi.fn().mockResolvedValue(new Response('ok'))
431+
vi.stubGlobal('fetch', fetchMock)
432+
const event = createH3Event(method, path)
433+
return proxyStorefrontRequest(event, context)
434+
}
435+
436+
function getAuthHeader(): string | undefined {
437+
const headers = fetchMock.mock.calls[0]![1].headers as {[key: string]: string}
438+
return headers.Authorization ?? headers.authorization
439+
}
440+
441+
test('excludes Bearer token for POST /cart/add.js', async () => {
442+
await stubFetchAndProxy('POST', '/cart/add.js', themeCtx)
443+
expect(getAuthHeader()).toBeUndefined()
444+
})
445+
446+
test('excludes Bearer token for POST /cart/update.js', async () => {
447+
await stubFetchAndProxy('POST', '/cart/update.js', themeCtx)
448+
expect(getAuthHeader()).toBeUndefined()
449+
})
450+
451+
test('excludes Bearer token for GET /cart.js', async () => {
452+
await stubFetchAndProxy('GET', '/cart.js', themeCtx)
453+
expect(getAuthHeader()).toBeUndefined()
454+
})
455+
456+
test('excludes Bearer token for GET /cart.json', async () => {
457+
await stubFetchAndProxy('GET', '/cart.json', themeCtx)
458+
expect(getAuthHeader()).toBeUndefined()
459+
})
460+
461+
test('excludes Bearer token for POST /cart (bare)', async () => {
462+
await stubFetchAndProxy('POST', '/cart', themeCtx)
463+
expect(getAuthHeader()).toBeUndefined()
464+
})
465+
466+
test('excludes Bearer token for POST /cart/change.js', async () => {
467+
await stubFetchAndProxy('POST', '/cart/change.js', themeCtx)
468+
expect(getAuthHeader()).toBeUndefined()
469+
})
470+
471+
test('excludes Bearer token for GET /checkouts/abc123', async () => {
472+
await stubFetchAndProxy('GET', '/checkouts/abc123', themeCtx)
473+
expect(getAuthHeader()).toBeUndefined()
474+
})
475+
476+
test('excludes Bearer token for GET /account/logout', async () => {
477+
await stubFetchAndProxy('GET', '/account/logout', themeCtx)
478+
expect(getAuthHeader()).toBeUndefined()
479+
})
480+
481+
test('excludes Bearer token for GET /cart.js?sections=header (query string)', async () => {
482+
await stubFetchAndProxy('GET', '/cart.js?sections=header', themeCtx)
483+
expect(getAuthHeader()).toBeUndefined()
484+
})
485+
486+
test('excludes Bearer token for POST /cart/add.js?sections=main-cart-items (query string)', async () => {
487+
await stubFetchAndProxy('POST', '/cart/add.js?sections=main-cart-items', themeCtx)
488+
expect(getAuthHeader()).toBeUndefined()
489+
})
490+
491+
test('excludes Bearer token for GET /account?foo=bar (query string with $ anchor)', async () => {
492+
await stubFetchAndProxy('GET', '/account?foo=bar', themeCtx)
493+
expect(getAuthHeader()).toBeUndefined()
494+
})
495+
496+
test('includes Bearer token for GET /cdn/shop/t/10/assets/theme.css', async () => {
497+
await stubFetchAndProxy('GET', '/cdn/shop/t/10/assets/theme.css', themeCtx)
498+
expect(getAuthHeader()).toBe('Bearer test-sfr-token')
499+
})
500+
501+
test('includes Bearer token for GET /some-path.js', async () => {
502+
await stubFetchAndProxy('GET', '/some-path.js', themeCtx)
503+
expect(getAuthHeader()).toBe('Bearer test-sfr-token')
504+
})
505+
506+
test('includes Bearer token for GET /checkouts/internal/something (negative lookahead)', async () => {
507+
await stubFetchAndProxy('GET', '/checkouts/internal/something', themeCtx)
508+
expect(getAuthHeader()).toBe('Bearer test-sfr-token')
509+
})
510+
511+
test('excludes Bearer token for theme-extension context', async () => {
512+
await stubFetchAndProxy('GET', '/cdn/shop/t/10/assets/theme.css', extensionCtx)
513+
expect(getAuthHeader()).toBeUndefined()
514+
})
404515
})
405516
})

packages/theme/src/cli/utilities/theme-environment/proxy.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export const EXTENSION_CDN_PREFIX = '/ext/cdn/'
1616

1717
const API_COLLECT_PATH = '/api/collect'
1818
const CART_PATTERN = /^\/cart\//
19+
// Broader than CART_PATTERN (routing) -- covers /cart, /cart.js, /cart.json for auth exclusion.
20+
// CART_PATTERN only matches /cart/... subpaths to avoid proxying GET /cart (the cart HTML page).
21+
const CART_SESSION_PATTERN = /^\/cart(\/|\.js|\.json|$)/
1922
const CHECKOUT_PATTERN = /^\/checkouts\/(?!internal\/)/
2023
const ACCOUNT_PATTERN = /^\/account(\/login\/multipass(\/[^/]+)?|\/logout)?\/?$/
2124
const VANITY_CDN_PATTERN = new RegExp(`^${VANITY_CDN_PREFIX}`)
@@ -114,6 +117,18 @@ export function canProxyRequest(event: H3Event) {
114117
return Boolean(extension) || acceptsType !== '*/*'
115118
}
116119

120+
// Cart, checkout, and account endpoints use session-cookie auth, not Bearer tokens.
121+
// CHECKOUT_PATTERN and ACCOUNT_PATTERN are reused from routing because their
122+
// routing and auth scopes currently align; changes to those patterns should
123+
// consider the auth implications here.
124+
function needsBearerToken(path: string): boolean {
125+
const [pathname] = path.split('?') as [string]
126+
if (CART_SESSION_PATTERN.test(pathname)) return false
127+
if (CHECKOUT_PATTERN.test(pathname)) return false
128+
if (ACCOUNT_PATTERN.test(pathname)) return false
129+
return true
130+
}
131+
117132
function getStoreFqdnForRegEx(ctx: DevServerContext) {
118133
return ctx.session.storeFqdn.replace(/\\/g, '\\\\').replace(/\./g, '\\.')
119134
}
@@ -326,8 +341,9 @@ export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): P
326341
Cookie: buildCookies(ctx.session, {headers}),
327342
}
328343

329-
// Only include Authorization for theme dev, not theme-extensions
330-
if (ctx.type === 'theme') {
344+
// Cart, checkout, and account endpoints use cookie-based auth.
345+
// Sending a Bearer token causes SFR to select token auth, which lacks cart scopes.
346+
if (ctx.type === 'theme' && needsBearerToken(event.path)) {
331347
baseHeaders.Authorization = `Bearer ${ctx.session.storefrontToken}`
332348
}
333349

0 commit comments

Comments
 (0)