diff --git a/.changeset/fix-admin-extension-cors-preflight.md b/.changeset/fix-admin-extension-cors-preflight.md new file mode 100644 index 0000000000..1f03a5caa9 --- /dev/null +++ b/.changeset/fix-admin-extension-cors-preflight.md @@ -0,0 +1,5 @@ +--- +"@shopify/shopify-app-express": patch +--- + +Respond to CORS preflight (`OPTIONS`) requests in `validateAuthenticatedSession` instead of trying to authenticate them. Admin UI extension `fetch` calls to an app's backend send a preflight request that carries no `Authorization` header, so the middleware would redirect/403 and the browser would block the real request on CORS. The middleware now short-circuits `OPTIONS` requests, responding `204` with the CORS headers for `https://extensions.shopifycdn.com`. diff --git a/packages/apps/shopify-app-express/src/middlewares/__tests__/validate-authenticated-session.test.ts b/packages/apps/shopify-app-express/src/middlewares/__tests__/validate-authenticated-session.test.ts index 49c25d1ba5..bd01ebbf56 100644 --- a/packages/apps/shopify-app-express/src/middlewares/__tests__/validate-authenticated-session.test.ts +++ b/packages/apps/shopify-app-express/src/middlewares/__tests__/validate-authenticated-session.test.ts @@ -225,6 +225,43 @@ describe('validateAuthenticatedSession', () => { expect((response.error as any).text).toBe('Storage error'); }); + + it('responds to a CORS preflight OPTIONS request without authenticating', async () => { + const getCurrentIdSpy = jest.spyOn(shopify.api.session, 'getCurrentId'); + + const response = await request(app) + .options('/test/shop') + .set('Origin', 'https://extensions.shopifycdn.com') + .expect(204); + + expect(response.headers['access-control-allow-origin']).toBe( + 'https://extensions.shopifycdn.com', + ); + expect(getCurrentIdSpy).not.toHaveBeenCalled(); + }); + + it('allows the Authorization header on the preflight response', async () => { + const response = await request(app) + .options('/test/shop') + .set('Origin', 'https://extensions.shopifycdn.com') + .expect(204); + + expect(response.headers['access-control-allow-headers']).toContain( + 'Authorization', + ); + }); + + it('allows the request methods on the preflight response', async () => { + const response = await request(app) + .options('/test/shop') + .set('Origin', 'https://extensions.shopifycdn.com') + .expect(204); + + expect(response.headers['access-control-allow-methods']).toContain('GET'); + expect(response.headers['access-control-allow-methods']).toContain( + 'POST', + ); + }); }); describe('for non-embedded apps', () => { diff --git a/packages/apps/shopify-app-express/src/middlewares/validate-authenticated-session.ts b/packages/apps/shopify-app-express/src/middlewares/validate-authenticated-session.ts index 2b97cd5baf..ab2d42b8bf 100644 --- a/packages/apps/shopify-app-express/src/middlewares/validate-authenticated-session.ts +++ b/packages/apps/shopify-app-express/src/middlewares/validate-authenticated-session.ts @@ -18,6 +18,17 @@ export function validateAuthenticatedSession({ return async (req: Request, res: Response, next: NextFunction) => { config.logger.debug('Running validateAuthenticatedSession'); + if (req.method === 'OPTIONS') { + res.set({ + 'Access-Control-Allow-Origin': 'https://extensions.shopifycdn.com', + 'Access-Control-Allow-Methods': + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + }); + res.status(204).end(); + return undefined; + } + let sessionId: string | undefined; try { sessionId = await api.session.getCurrentId({