Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-cart-ajax-401.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme': patch
---

Fix 401/403 on cart AJAX endpoints during `shopify theme dev`
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,96 @@ describe('dev proxy', () => {
})
})

describe('proxyStorefrontRequest — Bearer token auth scoping', () => {
let fetchMock: ReturnType<typeof vi.fn>
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<string, string>
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<string, string>
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<string, string>
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<string, string>
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<string, string>
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<string, string>
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<string, string>
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<string, string>
expect(headers.Authorization).toBeUndefined()
})
})

describe('proxyStorefrontRequest — Storefront API passthrough', () => {
const passthroughCtx = {
...ctx,
Expand Down
20 changes: 18 additions & 2 deletions packages/theme/src/cli/utilities/theme-environment/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down Expand Up @@ -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, '\\.')
}
Expand Down Expand Up @@ -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}`} : {}),
})
}

Expand Down
Loading