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
12 changes: 12 additions & 0 deletions .changeset/token-exchange-auth-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@shopify/shopify-app-express': minor
---

Add token exchange authentication strategy and hooks support

- Add `future` config option with feature flags (`unstable_newEmbeddedAuthStrategy`, `expiringOfflineAccessTokens`) to opt into upcoming behaviour changes
- Add `unstable_newEmbeddedAuthStrategy` flag: when enabled, `validateAuthenticatedSession` exchanges session tokens directly instead of redirecting to OAuth, and `ensureInstalledOnShop` skips the session check for embedded apps
- Add `hooks.afterAuth` async callback invoked after both OAuth and token exchange flows (deduplicated across concurrent requests)
- Add `registerWebhooks({session})` convenience method on the `ShopifyApp` object
- Add `expiringOfflineAccessTokens` flag to enable expiring offline access tokens in OAuth and token exchange flows
- Add `ensureOfflineTokenIsNotExpired` helper to proactively refresh offline tokens nearing expiry
4 changes: 4 additions & 0 deletions packages/apps/shopify-app-express/docs/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ This function returns an Express middleware that completes an OAuth process with

The session is available to the following handlers via the `res.locals.shopify.session` object.

When `future.expiringOfflineAccessTokens` is enabled, the `callback()` middleware requests expiring offline access tokens from Shopify (tokens that include a `refreshToken` and expire after a set period). The package automatically refreshes these tokens before they expire on subsequent requests.

The `hooks.afterAuth` function, if configured, is called automatically at the end of the `callback()` middleware, so custom post-auth logic (such as webhook registration) no longer needs to be added as a separate middleware in the route chain.

> **Note**: this middleware **_DOES NOT_** redirect anywhere, so the request **_WILL NOT_** trigger a response by default. If you don't need to perform any actions after OAuth, we recommend using the `shopify.redirectToShopifyOrAppRoot()` middleware.

## Example
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Migrating to Token Exchange Authentication

This guide walks you through migrating an embedded Express app from the OAuth redirect flow to the token exchange flow.

## Prerequisites

Before enabling token exchange, your app must meet the following requirements:

1. **Shopify managed installation**: Your app must use [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation). This means Shopify handles the installation process, and your app does not need to redirect merchants through an OAuth grant screen.

2. **Scopes declared in `shopify.app.toml`**: Your app's access scopes must be declared in your `shopify.app.toml` configuration file rather than passed to the `shopifyApp()` function. Shopify uses these scopes during managed installation.

3. **Embedded app**: Token exchange is designed for embedded apps that run inside the Shopify Admin. Non-embedded apps should continue using the OAuth redirect flow.

## Enabling Token Exchange

Set the `unstable_newEmbeddedAuthStrategy` future flag when configuring your app:

```ts
const shopify = shopifyApp({
api: {
apiKey: 'ApiKeyFromPartnersDashboard',
apiSecretKey: 'ApiSecretKeyFromPartnersDashboard',
hostScheme: 'http',
hostName: `localhost:${PORT}`,
// Note: scopes are declared in shopify.app.toml, not here
},
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
webhooks: {
path: '/webhooks',
},
future: {
unstable_newEmbeddedAuthStrategy: true,
},
});
```

## Removing OAuth Routes

With Shopify managed installation and token exchange enabled, the OAuth redirect flow is never used for embedded apps. The `/auth` and `/auth/callback` routes become dead code and can be removed:

```ts
// These routes are no longer needed with token exchange + managed install.
// app.get(shopify.config.auth.path, shopify.auth.begin());
// app.get(
// shopify.config.auth.callbackPath,
// shopify.auth.callback(),
// shopify.redirectToShopifyOrAppRoot(),
// );
```

> **Note**: Keep these routes if your app also supports non-embedded (standalone) installation scenarios.

## Moving Webhook Registration to `afterAuth`

With the OAuth flow, webhooks are automatically registered during the OAuth callback. When using token exchange, authentication no longer goes through the OAuth callback on every request, so webhook registration should be moved to the `afterAuth` hook using `shopify.registerWebhooks`.

The `afterAuth` hook is called after a merchant successfully authenticates — both via the OAuth callback (during initial installation) and via token exchange (on subsequent requests when a new session is created).

```ts
const shopify = shopifyApp({
// ...api, auth, webhooks config
future: {
unstable_newEmbeddedAuthStrategy: true,
},
hooks: {
afterAuth: async ({session}) => {
// Register webhooks using the convenience method on the shopify object
await shopify.registerWebhooks({session});

// Any other post-auth setup (e.g., database seeding)
},
},
});
```

> **Note**: In the token exchange path, the `afterAuth` hook is deduplicated — if App Bridge retries a request with the same session token, the hook will only execute once.

## How the Flow Works End-to-End

```
Browser (embedded in Shopify Admin)
├─ 1. Page load → GET /my-page?shop=...&embedded=1
│ ensureInstalledOnShop detects the flag is ON, skips session check,
│ sets CSP headers, and calls next() — no OAuth redirect.
├─ 2. Frontend loads → App Bridge initialises and obtains a session token
│ (a JWT signed by Shopify for your app's API key).
├─ 3. API call → GET /api/products
│ Authorization: Bearer <session-token>
│ validateAuthenticatedSession detects the Bearer token and calls
│ performTokenExchange.
├─ 4. Token exchange → the library calls Shopify's token exchange endpoint
│ using the session token as a subject token. Shopify returns an
│ offline (and optionally online) access token.
└─ 5. afterAuth hook fires → shopify.registerWebhooks({session}) registers
any webhook topics declared in your config for this shop.
```

## Key Differences from the OAuth Flow

| Feature | OAuth redirect | Token exchange |
|---|---|---|
| **Initial install** | Redirects merchant through OAuth consent | Shopify managed — no redirect needed |
| **Embedded page load** | Requires session in DB; redirects to OAuth if missing | Skips session check; always loads the app |
| **Access token acquisition** | Auth code exchanged server-side after redirect | JWT session token exchanged on first API request |
| **Redirect flickering** | Visible redirects when the session expires or is missing | No redirects — authentication happens transparently |
| **Webhook registration** | Automatic inside `auth.callback()` | Manual via `afterAuth` + `shopify.registerWebhooks` |
| **Non-embedded support** | Full support | Falls back to redirect for non-embedded installs |
| **Concurrent request deduplication** | N/A | `afterAuth` called once per session token |

## Using Expiring Offline Access Tokens

The `expiringOfflineAccessTokens` future flag can be used alongside token exchange (or independently with the OAuth flow). When enabled, offline access tokens include a `refreshToken` and expire after a set period. The package automatically refreshes these tokens when they are within 5 minutes of expiry.

```ts
const shopify = shopifyApp({
// ...api, auth, webhooks config
future: {
unstable_newEmbeddedAuthStrategy: true,
expiringOfflineAccessTokens: true,
},
hooks: {
afterAuth: async ({session}) => {
await shopify.registerWebhooks({session});
},
},
});
```

When both flags are enabled:

- **Token exchange path**: If the existing session is within 5 minutes of expiry, a fresh token exchange is performed automatically. The new session includes an updated `refreshToken`.
- **OAuth path**: After validating the session, the middleware checks whether the offline token is close to expiry and refreshes it using the stored `refreshToken` before continuing.

In both cases, the refreshed session is stored automatically -- no additional code is needed in your app.
47 changes: 47 additions & 0 deletions packages/apps/shopify-app-express/docs/reference/shopifyApp.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ Learn more about [access modes in Shopify APIs](https://shopify.dev/docs/apps/au
The path your app's frontend uses to trigger an App Bridge redirect to leave the Shopify Admin before starting OAuth.
Since that page is in the app frontend, we don't include it in this package, but you can find [an example in our template](https://github.com/Shopify/shopify-frontend-template-react/blob/main/pages/ExitIframe.jsx).

### future

`object` | Defaults to `{}`

Features that will be introduced in future releases of this package. You can opt in to these features by setting the corresponding flags.

#### unstable_newEmbeddedAuthStrategy

`boolean` | Defaults to `false`

When enabled, embedded apps fetch access tokens via [token exchange](https://shopify.dev/docs/apps/auth/get-access-tokens/token-exchange) instead of the OAuth redirect flow. Your app must use [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation) (scopes declared in `shopify.app.toml`).

#### expiringOfflineAccessTokens

`boolean` | Defaults to `false`

When enabled, the app requests expiring offline access tokens and automatically refreshes them when they are within 5 minutes of expiry. Can be used with either the OAuth or token exchange flow.

### hooks

`object` | Optional

Functions to call at key points during the app lifecycle.

#### afterAuth

`(options: {session: Session}) => void | Promise<void>`

Called after a merchant successfully authenticates — both via OAuth callback and via token exchange. In the token exchange path this hook is deduplicated: it will only be called once per session token even if multiple API requests arrive concurrently. Use this hook for post-auth setup such as webhook registration or database seeding.

## Return

Returns an object that contains everything an app needs to interact with Shopify:
Expand Down Expand Up @@ -107,6 +137,23 @@ A function that returns an Express middleware that redirects the user to the app

A function that redirects to any URL at the browser's top level, regardless of where the request originated from.

### registerWebhooks

`(params: {session: Session}) => Promise<void>`

Registers the webhook topics declared in the `webhooks` config for a given shop. Call this inside `hooks.afterAuth` so webhooks are registered after every authentication — both OAuth and token exchange.

```ts
const shopify = shopifyApp({
webhooks: {path: '/webhooks'},
hooks: {
afterAuth: async ({session}) => {
shopify.registerWebhooks({session});
},
},
});
```

## Example

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ If the verification fails in either case, it will redirect the user to complete

Please visit [our documentation](https://shopify.dev/docs/apps/auth/oauth/session-tokens) to learn more about session tokens and how they work.

## Token Exchange (embedded apps)

When `future.unstable_newEmbeddedAuthStrategy` is enabled, this middleware uses a different authentication path for embedded apps. Instead of redirecting to OAuth, it:

1. Reads the Shopify session token (JWT) from the `Authorization: Bearer` header sent by App Bridge.
2. Checks whether a valid session already exists in storage.
3. If no valid session exists, exchanges the session token for API access tokens directly via the Shopify token exchange API -- no redirect required.

This eliminates the redirect flickering that occurs with the OAuth flow and improves the embedded app experience.

## Example

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,41 @@ export const PUBSUB_WEBHOOK_UPDATE_RESPONSE = {
},
},
};

export const OFFLINE_TOKEN_EXCHANGE_RESPONSE = {
access_token: 'offline-token-exchange-token',
scope: 'testScope',
};

export const ONLINE_TOKEN_EXCHANGE_RESPONSE = {
access_token: 'online-token-exchange-token',
scope: 'testScope',
expires_in: 123456,
associated_user_scope: 'testScope',
associated_user: {
id: 1234,
first_name: 'first',
last_name: 'last',
email: 'email',
email_verified: true,
account_owner: true,
locale: 'en',
collaborator: true,
},
};

export const EXPIRING_OFFLINE_TOKEN_EXCHANGE_RESPONSE = {
access_token: 'expiring-offline-token',
scope: 'testScope',
expires_in: 86400,
refresh_token: 'refresh-token-value',
refresh_token_expires_in: 604800,
};

export const REFRESH_TOKEN_RESPONSE = {
access_token: 'refreshed-access-token',
scope: 'testScope',
expires_in: 86400,
refresh_token: 'new-refresh-token',
refresh_token_expires_in: 604800,
};
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ beforeEach(() => {
webhooks: {
path: '/webhooks',
},
future: {},
sessionStorage: new MemorySessionStorage(),
api: {
apiKey: 'testApiKey',
Expand Down
Loading
Loading