diff --git a/.changeset/fix-cart-ajax-401.md b/.changeset/fix-cart-ajax-401.md new file mode 100644 index 00000000000..3b29c772dfb --- /dev/null +++ b/.changeset/fix-cart-ajax-401.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Fix 401/403 on cart AJAX endpoints during `shopify theme dev` diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index 3dbf934b9ee..fa5ea3cef70 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -553,6 +553,96 @@ describe('dev proxy', () => { }) }) + describe('proxyStorefrontRequest — Bearer token auth scoping', () => { + let fetchMock: ReturnType + const tokenCtx = { + ...ctx, + type: 'theme', + session: { + storeFqdn: 'my-store.myshopify.com', + sessionCookies: {}, + storefrontToken: 'sfr-devtools-token', + }, + } as unknown as DevServerContext + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(new Response('OK')) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('sends Bearer token for CDN asset requests', async () => { + const event = createH3Event('GET', '/cdn/shop/files/style.css') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer sfr-devtools-token') + }) + + test('does NOT send Bearer token for /cart/add.js', async () => { + const event = createH3Event('POST', '/cart/add.js') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('does NOT send Bearer token for /cart.js', async () => { + const event = createH3Event('GET', '/cart.js') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('does NOT send Bearer token for /cart.json', async () => { + const event = createH3Event('GET', '/cart.json') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('does NOT send Bearer token for /cart/', async () => { + const event = createH3Event('GET', '/cart/') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('does NOT send Bearer token for checkout endpoints', async () => { + const event = createH3Event('GET', '/checkouts/xyz') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('does NOT send Bearer token for account endpoints', async () => { + const event = createH3Event('GET', '/account/logout') + await proxyStorefrontRequest(event, tokenCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + test('sends Bearer token for theme-extension type', async () => { + const extCtx = { + ...tokenCtx, + type: 'theme-extensions', + } as unknown as DevServerContext + const event = createH3Event('GET', '/assets/style.css') + await proxyStorefrontRequest(event, extCtx) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + }) + describe('proxyStorefrontRequest — Storefront API passthrough', () => { const passthroughCtx = { ...ctx, diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index deaa82d9b34..f9894b75f3d 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -17,6 +17,9 @@ export const EXTENSION_CDN_PREFIX = '/ext/cdn/' const API_COLLECT_PATH = '/api/collect' const CART_PATTERN = /^\/cart\// +// Broader than CART_PATTERN (routing) -- covers /cart, /cart.js, /cart.json for auth exclusion. +// CART_PATTERN only matches /cart/... subpaths to avoid proxying GET /cart (the cart HTML page). +const CART_SESSION_PATTERN = /^\/cart(\/|\.js|\.json|$)/ const CHECKOUT_PATTERN = /^\/checkouts\/(?!internal\/)/ const ACCOUNT_PATTERN = /^\/account(\/login\/multipass(\/[^/]+)?|\/logout)?\/?$/ const VANITY_CDN_PATTERN = new RegExp(`^${VANITY_CDN_PREFIX}`) @@ -116,6 +119,18 @@ export function canProxyRequest(event: H3Event) { return Boolean(extension) || acceptsType !== '*/*' } +// Cart, checkout, and account endpoints use session-cookie auth, not Bearer tokens. +// CHECKOUT_PATTERN and ACCOUNT_PATTERN are reused from routing because their +// routing and auth scopes currently align; changes to those patterns should +// consider the auth implications here. +function needsBearerToken(path: string): boolean { + const [pathname] = path.split('?') as [string] + if (CART_SESSION_PATTERN.test(pathname)) return false + if (CHECKOUT_PATTERN.test(pathname)) return false + if (ACCOUNT_PATTERN.test(pathname)) return false + return true +} + function getStoreFqdnForRegEx(ctx: DevServerContext) { return ctx.session.storeFqdn.replace(/\\/g, '\\\\').replace(/\./g, '\\.') } @@ -344,8 +359,9 @@ export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): P ...(host === ctx.session.storeFqdn ? ctx.session.crawlerSignatureHeaders : {}), referer: url.origin, Cookie: buildCookies(ctx.session, {headers}), - // Only include Authorization for theme dev, not theme-extensions - ...(ctx.type === 'theme' ? {Authorization: `Bearer ${ctx.session.storefrontToken}`} : {}), + // Cart, checkout, and account endpoints use cookie-based auth. + // Sending a Bearer token causes SFR to select token auth, which lacks cart scopes. + ...(ctx.type === 'theme' && needsBearerToken(event.path) ? {Authorization: `Bearer ${ctx.session.storefrontToken}`} : {}), }) }