diff --git a/.changeset/cookie-path-config.md b/.changeset/cookie-path-config.md new file mode 100644 index 0000000000..21d27dd6b8 --- /dev/null +++ b/.changeset/cookie-path-config.md @@ -0,0 +1,26 @@ +--- +'@shopify/shopify-api': minor +--- + +Add `cookiePath` config option for multi-shop non-embedded apps + +Non-embedded apps that need to support multiple shops simultaneously in +separate browser tabs were affected by a cookie collision: all shops shared +a single `shopify_app_session` cookie at `path=/`, so authenticating a new +shop would silently overwrite the previous shop's session. + +The new optional `cookiePath` config option lets you scope the session +cookie to a shop-specific URL prefix, so each shop's cookie coexists +independently in the browser. + +```ts +// Static path (default behaviour, unchanged) +cookiePath: '/' + +// Factory function — recommended for multi-shop apps +cookiePath: (session) => `/shops/${session.shop}/` +``` + +**Requirement:** the configured path must match your app's URL structure. +Each shop must be served under a distinct URL prefix for the browser to +deliver the correct cookie per request. diff --git a/packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts b/packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts index cb17527d7f..e5eed569f4 100644 --- a/packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts +++ b/packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts @@ -721,6 +721,126 @@ describe('callback', () => { responseCookies.shopify_app_session.expires?.getTime(), ).toBeUndefined(); }); + + test('uses default path "/" for session cookie when cookiePath is not configured', async () => { + const shopify = shopifyApi(testConfig({isEmbeddedApp: false})); + + const beginResponse: NormalizedResponse = await shopify.auth.begin({ + shop, + isOnline: false, + callbackPath: '/some-callback', + rawRequest: request, + }); + setCallbackCookieFromResponse( + request, + beginResponse, + shopify.config.apiSecretKey, + ); + + const testCallbackQuery: QueryMock = { + shop, + state: VALID_NONCE, + timestamp: getCurrentTimeInSec().toString(), + code: 'some random auth code', + }; + const expectedHmac = await generateLocalHmac(shopify.config)( + testCallbackQuery, + ); + testCallbackQuery.hmac = expectedHmac; + request.url += `?${new URLSearchParams(testCallbackQuery).toString()}`; + + queueMockResponse(JSON.stringify({access_token: 'token', scope: ''})); + + const callbackResponse = await shopify.auth.callback({rawRequest: request}); + const responseCookies = Cookies.parseCookies( + callbackResponse.headers['Set-Cookie'], + ); + + expect(responseCookies.shopify_app_session.path).toEqual('/'); + }); + + test('uses static cookiePath string for session cookie', async () => { + const shopify = shopifyApi( + testConfig({isEmbeddedApp: false, cookiePath: '/my-app/'}), + ); + + const beginResponse: NormalizedResponse = await shopify.auth.begin({ + shop, + isOnline: false, + callbackPath: '/some-callback', + rawRequest: request, + }); + setCallbackCookieFromResponse( + request, + beginResponse, + shopify.config.apiSecretKey, + ); + + const testCallbackQuery: QueryMock = { + shop, + state: VALID_NONCE, + timestamp: getCurrentTimeInSec().toString(), + code: 'some random auth code', + }; + const expectedHmac = await generateLocalHmac(shopify.config)( + testCallbackQuery, + ); + testCallbackQuery.hmac = expectedHmac; + request.url += `?${new URLSearchParams(testCallbackQuery).toString()}`; + + queueMockResponse(JSON.stringify({access_token: 'token', scope: ''})); + + const callbackResponse = await shopify.auth.callback({rawRequest: request}); + const responseCookies = Cookies.parseCookies( + callbackResponse.headers['Set-Cookie'], + ); + + expect(responseCookies.shopify_app_session.path).toEqual('/my-app/'); + }); + + test('uses cookiePath factory function to derive path from session', async () => { + const shopify = shopifyApi( + testConfig({ + isEmbeddedApp: false, + cookiePath: (session) => `/shops/${session.shop}/`, + }), + ); + + const beginResponse: NormalizedResponse = await shopify.auth.begin({ + shop, + isOnline: false, + callbackPath: '/some-callback', + rawRequest: request, + }); + setCallbackCookieFromResponse( + request, + beginResponse, + shopify.config.apiSecretKey, + ); + + const testCallbackQuery: QueryMock = { + shop, + state: VALID_NONCE, + timestamp: getCurrentTimeInSec().toString(), + code: 'some random auth code', + }; + const expectedHmac = await generateLocalHmac(shopify.config)( + testCallbackQuery, + ); + testCallbackQuery.hmac = expectedHmac; + request.url += `?${new URLSearchParams(testCallbackQuery).toString()}`; + + queueMockResponse(JSON.stringify({access_token: 'token', scope: ''})); + + const callbackResponse = await shopify.auth.callback({rawRequest: request}); + const responseCookies = Cookies.parseCookies( + callbackResponse.headers['Set-Cookie'], + ); + + expect(responseCookies.shopify_app_session.path).toEqual( + `/shops/${shop}/`, + ); + }); }); function setCallbackCookieFromResponse( diff --git a/packages/apps/shopify-api/lib/auth/oauth/oauth.ts b/packages/apps/shopify-api/lib/auth/oauth/oauth.ts index b7a9fe0dc9..56cbeece11 100644 --- a/packages/apps/shopify-api/lib/auth/oauth/oauth.ts +++ b/packages/apps/shopify-api/lib/auth/oauth/oauth.ts @@ -217,11 +217,15 @@ export function callback(config: ConfigInterface): OAuthCallback { }); if (!config.isEmbeddedApp) { + const cookiePath = + typeof config.cookiePath === 'function' + ? config.cookiePath(session) + : (config.cookiePath ?? '/'); await cookies.setAndSign(SESSION_COOKIE_NAME, session.id, { expires: session.expires, sameSite: 'lax', secure: true, - path: '/', + path: cookiePath, }); } diff --git a/packages/apps/shopify-api/lib/base-types.ts b/packages/apps/shopify-api/lib/base-types.ts index 3f7b9ef43c..4f4efc45e3 100644 --- a/packages/apps/shopify-api/lib/base-types.ts +++ b/packages/apps/shopify-api/lib/base-types.ts @@ -3,6 +3,7 @@ import {ShopifyRestResources} from '../rest/types'; import {AuthScopes} from './auth/scopes'; import {BillingConfig} from './billing/types'; +import {Session} from './session/session'; import {ApiVersion, DomainTransformation, LogSeverity} from './types'; /** @@ -135,6 +136,33 @@ export interface ConfigParams< * @private */ _logDisabledFutureFlags?: boolean; + /** + * The path to use for the OAuth session cookie in non-embedded apps. + * + * By default the cookie is written with `path: '/'`, making it domain-wide. + * This means that when a user authenticates multiple shops in separate tabs, + * each OAuth callback overwrites the previous cookie, causing all tabs to use + * the most-recently-authenticated shop. + * + * Set this to a string or a function returning a string to scope the cookie to + * a URL path prefix that is unique per shop. The browser will then maintain + * one cookie per shop and deliver only the matching one per request. + * + * **Requirement:** the configured path must match the actual URL structure of + * your app — e.g. if each shop lives under `/shops/:shop/`, use that prefix. + * The library cannot derive this automatically. + * + * @example + * // Static path (single-shop apps or apps with no shop-specific routing) + * cookiePath: '/' + * + * @example + * // Factory function (multi-shop non-embedded apps) + * cookiePath: (session) => `/shops/${session.shop}/` + * + * @defaultValue `'/'` + */ + cookiePath?: string | ((session: Session) => string); /** * Whether the app is initialised for local testing. */