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
26 changes: 26 additions & 0 deletions .changeset/cookie-path-config.md
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 120 additions & 0 deletions packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,126 @@
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(

Check failure on line 840 in packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎······`/shops/${shop}/`,⏎····` with ``/shops/${shop}/``
`/shops/${shop}/`,
);
});
});

function setCallbackCookieFromResponse(
Expand Down
6 changes: 5 additions & 1 deletion packages/apps/shopify-api/lib/auth/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
28 changes: 28 additions & 0 deletions packages/apps/shopify-api/lib/base-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading