diff --git a/.changeset/token-exchange-auth-strategy.md b/.changeset/token-exchange-auth-strategy.md new file mode 100644 index 0000000000..79cf726a5d --- /dev/null +++ b/.changeset/token-exchange-auth-strategy.md @@ -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 diff --git a/packages/apps/shopify-app-express/docs/reference/auth.md b/packages/apps/shopify-app-express/docs/reference/auth.md index 3b08043b01..1268579971 100644 --- a/packages/apps/shopify-app-express/docs/reference/auth.md +++ b/packages/apps/shopify-app-express/docs/reference/auth.md @@ -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 diff --git a/packages/apps/shopify-app-express/docs/reference/guides/token-exchange.md b/packages/apps/shopify-app-express/docs/reference/guides/token-exchange.md new file mode 100644 index 0000000000..c4f6b66ac5 --- /dev/null +++ b/packages/apps/shopify-app-express/docs/reference/guides/token-exchange.md @@ -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 + │ 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. diff --git a/packages/apps/shopify-app-express/docs/reference/shopifyApp.md b/packages/apps/shopify-app-express/docs/reference/shopifyApp.md index 0136cdddf5..8726a142b5 100644 --- a/packages/apps/shopify-app-express/docs/reference/shopifyApp.md +++ b/packages/apps/shopify-app-express/docs/reference/shopifyApp.md @@ -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` + +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: @@ -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` + +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 diff --git a/packages/apps/shopify-app-express/docs/reference/validateAuthenticatedSession.md b/packages/apps/shopify-app-express/docs/reference/validateAuthenticatedSession.md index f794b6acc4..559dbc81c1 100644 --- a/packages/apps/shopify-app-express/docs/reference/validateAuthenticatedSession.md +++ b/packages/apps/shopify-app-express/docs/reference/validateAuthenticatedSession.md @@ -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 diff --git a/packages/apps/shopify-app-express/src/__tests__/integration/responses.ts b/packages/apps/shopify-app-express/src/__tests__/integration/responses.ts index 9682950346..dc30d1f854 100644 --- a/packages/apps/shopify-app-express/src/__tests__/integration/responses.ts +++ b/packages/apps/shopify-app-express/src/__tests__/integration/responses.ts @@ -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, +}; diff --git a/packages/apps/shopify-app-express/src/__tests__/test-helper.ts b/packages/apps/shopify-app-express/src/__tests__/test-helper.ts index 0da35787d2..2c8a9db259 100644 --- a/packages/apps/shopify-app-express/src/__tests__/test-helper.ts +++ b/packages/apps/shopify-app-express/src/__tests__/test-helper.ts @@ -39,6 +39,7 @@ beforeEach(() => { webhooks: { path: '/webhooks', }, + future: {}, sessionStorage: new MemorySessionStorage(), api: { apiKey: 'testApiKey', diff --git a/packages/apps/shopify-app-express/src/auth/__tests__/auth.test.ts b/packages/apps/shopify-app-express/src/auth/__tests__/auth.test.ts index 9fcbe939e9..8ab81f5db5 100644 --- a/packages/apps/shopify-app-express/src/auth/__tests__/auth.test.ts +++ b/packages/apps/shopify-app-express/src/auth/__tests__/auth.test.ts @@ -301,3 +301,108 @@ describe('auth with action after callback', () => { expect(afterAuth).toHaveBeenCalled(); }); }); + +describe('auth with expiringOfflineAccessTokens', () => { + let app: express.Express; + let session: Session; + let callbackMock: jest.SpiedFunction; + + beforeEach(async () => { + shopify.config.future = {expiringOfflineAccessTokens: true}; + + app = express(); + app.get('/auth', shopify.auth.begin()); + app.get( + '/auth/callback', + shopify.auth.callback(), + shopify.redirectToShopifyOrAppRoot(), + ); + + session = new Session({ + id: 'test-session', + isOnline: shopify.config.useOnlineTokens, + shop: TEST_SHOP, + state: '1234', + accessToken: 'test-access-token', + }); + + callbackMock = jest.spyOn(shopify.api.auth, 'callback'); + callbackMock.mockResolvedValueOnce({session, headers: undefined}); + jest.spyOn(shopify.api.webhooks, 'register').mockResolvedValueOnce({}); + }); + + it('passes expiring flag to api.auth.callback()', async () => { + await request(app).get(`/auth/callback?host=${BASE64_HOST}`).expect(302); + + expect(callbackMock).toHaveBeenCalledWith( + expect.objectContaining({ + expiring: true, + }), + ); + }); +}); + +describe('auth with afterAuth hook', () => { + let app: express.Express; + let session: Session; + let afterAuthHook: jest.Mock; + + beforeEach(async () => { + afterAuthHook = jest.fn(); + shopify.config.hooks = {afterAuth: afterAuthHook}; + + app = express(); + app.get('/auth', shopify.auth.begin()); + app.get( + '/auth/callback', + shopify.auth.callback(), + shopify.redirectToShopifyOrAppRoot(), + ); + + session = new Session({ + id: 'test-session', + isOnline: shopify.config.useOnlineTokens, + shop: TEST_SHOP, + state: '1234', + accessToken: 'test-access-token', + }); + + jest + .spyOn(shopify.api.auth, 'callback') + .mockResolvedValueOnce({session, headers: undefined}); + jest.spyOn(shopify.api.webhooks, 'register').mockResolvedValueOnce({}); + }); + + it('calls afterAuth hook after OAuth callback completes', async () => { + await request(app).get(`/auth/callback?host=${BASE64_HOST}`).expect(302); + + expect(afterAuthHook).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + shop: TEST_SHOP, + accessToken: 'test-access-token', + }), + }), + ); + }); + + it('still calls registerWebhooks even when afterAuth hook is set', async () => { + const registerMock = jest.spyOn(shopify.api.webhooks, 'register'); + registerMock.mockReset(); + registerMock.mockResolvedValueOnce({}); + + jest.spyOn(shopify.api.auth, 'callback').mockReset(); + jest + .spyOn(shopify.api.auth, 'callback') + .mockResolvedValueOnce({session, headers: undefined}); + + await request(app).get(`/auth/callback?host=${BASE64_HOST}`).expect(302); + + expect(registerMock).toHaveBeenCalledWith({ + session: expect.objectContaining({ + shop: TEST_SHOP, + }), + }); + expect(afterAuthHook).toHaveBeenCalled(); + }); +}); diff --git a/packages/apps/shopify-app-express/src/auth/auth-callback.ts b/packages/apps/shopify-app-express/src/auth/auth-callback.ts index 37ad7251f7..59813e15a6 100644 --- a/packages/apps/shopify-app-express/src/auth/auth-callback.ts +++ b/packages/apps/shopify-app-express/src/auth/auth-callback.ts @@ -23,6 +23,7 @@ export async function authCallback({ const callbackResponse = await api.auth.callback({ rawRequest: req, rawResponse: res, + expiring: config.future?.expiringOfflineAccessTokens, }); config.logger.debug('Callback is valid, storing session', { @@ -53,6 +54,8 @@ export async function authCallback({ session: callbackResponse.session, }; + await config.hooks?.afterAuth?.({session: callbackResponse.session}); + config.logger.debug('Completed OAuth callback', { shop: callbackResponse.session.shop, isOnline: callbackResponse.session.isOnline, diff --git a/packages/apps/shopify-app-express/src/config-types.ts b/packages/apps/shopify-app-express/src/config-types.ts index 7b48e574ce..176f5774e6 100644 --- a/packages/apps/shopify-app-express/src/config-types.ts +++ b/packages/apps/shopify-app-express/src/config-types.ts @@ -1,11 +1,15 @@ import { ApiVersion, ConfigParams as ApiConfigParams, + Session, Shopify, ShopifyRestResources, } from '@shopify/shopify-api'; import {SessionStorage} from '@shopify/shopify-app-session-storage'; +import {FutureFlags, FutureFlagOptions} from './future/flags'; +import {IdempotentPromiseHandler} from './helpers/idempotent-promise-handler'; + // Make apiVersion required while keeping other API config fields optional export type ExpressApiConfigParams< Resources extends ShopifyRestResources = ShopifyRestResources, @@ -16,6 +20,7 @@ export type ExpressApiConfigParams< export interface AppConfigParams< Resources extends ShopifyRestResources = ShopifyRestResources, Storage extends SessionStorage = SessionStorage, + Future extends FutureFlagOptions = FutureFlagOptions, > { auth: AuthConfigInterface; webhooks: WebhooksConfigInterface; @@ -23,6 +28,10 @@ export interface AppConfigParams< useOnlineTokens?: boolean; exitIframePath?: string; sessionStorage?: Storage; + future?: Future; + hooks?: { + afterAuth?: (options: {session: Session}) => void | Promise; + }; } export interface AppConfigInterface< @@ -33,6 +42,11 @@ export interface AppConfigInterface< useOnlineTokens: boolean; exitIframePath: string; sessionStorage: Storage; + future: FutureFlags; + hooks: { + afterAuth?: (options: {session: Session}) => void | Promise; + }; + idempotentPromiseHandler: IdempotentPromiseHandler; } export interface AuthConfigInterface { diff --git a/packages/apps/shopify-app-express/src/future/flags.ts b/packages/apps/shopify-app-express/src/future/flags.ts new file mode 100644 index 0000000000..04e72959cc --- /dev/null +++ b/packages/apps/shopify-app-express/src/future/flags.ts @@ -0,0 +1,45 @@ +import {Shopify} from '@shopify/shopify-api'; + +export interface FutureFlags { + /** + * When enabled, embedded apps will fetch access tokens via token exchange + * instead of the OAuth redirect flow. Requires Shopify managed installation. + * @default false + */ + unstable_newEmbeddedAuthStrategy?: boolean; + + /** + * When enabled, the app will use expiring offline access tokens and + * automatically refresh them when they are close to expiring. + * @default false + */ + expiringOfflineAccessTokens?: boolean; +} + +export type FutureFlagOptions = FutureFlags | undefined; + +export type FeatureEnabled< + Future extends FutureFlagOptions, + Flag extends keyof FutureFlags, +> = Future extends FutureFlags + ? Future[Flag] extends true + ? true + : false + : false; + +// Logs a startup hint when unstable_newEmbeddedAuthStrategy is disabled. +export function logDisabledFutureFlags( + config: {future: FutureFlags}, + logger: Shopify['logger'], +): void { + const logFlag = (flag: string, message: string) => + logger.info(`Future flag ${flag} is disabled.\n\n ${message}\n`); + + if (!config.future.unstable_newEmbeddedAuthStrategy) { + logFlag( + 'unstable_newEmbeddedAuthStrategy', + 'Enable this to use OAuth token exchange instead of auth code to generate API access tokens.' + + '\n Your app must be using Shopify managed install: https://shopify.dev/docs/apps/auth/installation', + ); + } +} diff --git a/packages/apps/shopify-app-express/src/helpers/ensure-offline-token-is-not-expired.ts b/packages/apps/shopify-app-express/src/helpers/ensure-offline-token-is-not-expired.ts new file mode 100644 index 0000000000..ee0d9af717 --- /dev/null +++ b/packages/apps/shopify-app-express/src/helpers/ensure-offline-token-is-not-expired.ts @@ -0,0 +1,29 @@ +import {Session, Shopify} from '@shopify/shopify-api'; + +import {AppConfigInterface} from '../config-types'; + +import refreshToken from './refresh-token'; + +// 5 minutes +export const WITHIN_MILLISECONDS_OF_EXPIRY = 5 * 60 * 1000; + +export async function ensureOfflineTokenIsNotExpired( + session: Session, + api: Shopify, + config: AppConfigInterface, +): Promise { + if ( + config.future?.expiringOfflineAccessTokens && + session.isExpired(WITHIN_MILLISECONDS_OF_EXPIRY) && + session.refreshToken + ) { + const offlineSession = await refreshToken( + api, + session.shop, + session.refreshToken, + ); + await config.sessionStorage.storeSession(offlineSession); + return offlineSession; + } + return session; +} diff --git a/packages/apps/shopify-app-express/src/helpers/idempotent-promise-handler.ts b/packages/apps/shopify-app-express/src/helpers/idempotent-promise-handler.ts new file mode 100644 index 0000000000..e944546ad0 --- /dev/null +++ b/packages/apps/shopify-app-express/src/helpers/idempotent-promise-handler.ts @@ -0,0 +1,45 @@ +export interface IdempotentHandlePromiseParams { + promiseFunction: () => Promise; + identifier: string; +} + +const IDENTIFIER_TTL_MS = 60000; + +export class IdempotentPromiseHandler { + protected identifiers: Map; + + constructor() { + this.identifiers = new Map(); + } + + async handlePromise({ + promiseFunction, + identifier, + }: IdempotentHandlePromiseParams): Promise { + try { + if (this.isPromiseRunnable(identifier)) { + await promiseFunction(); + } + } finally { + this.clearStaleIdentifiers(); + } + + return Promise.resolve(); + } + + private isPromiseRunnable(identifier: string) { + if (!this.identifiers.has(identifier)) { + this.identifiers.set(identifier, Date.now()); + return true; + } + return false; + } + + private async clearStaleIdentifiers() { + this.identifiers.forEach((date, identifier, map) => { + if (Date.now() - date > IDENTIFIER_TTL_MS) { + map.delete(identifier); + } + }); + } +} diff --git a/packages/apps/shopify-app-express/src/helpers/refresh-token.ts b/packages/apps/shopify-app-express/src/helpers/refresh-token.ts new file mode 100644 index 0000000000..c641dd7fb1 --- /dev/null +++ b/packages/apps/shopify-app-express/src/helpers/refresh-token.ts @@ -0,0 +1,31 @@ +import { + HttpResponseError, + InvalidJwtError, + Session, + Shopify, +} from '@shopify/shopify-api'; + +export default async function refreshToken( + api: Shopify, + shop: string, + refreshTokenValue: string, +): Promise { + try { + const {session} = await api.auth.refreshToken({ + shop, + refreshToken: refreshTokenValue, + }); + return session; + } catch (error) { + if ( + error instanceof InvalidJwtError || + (error instanceof HttpResponseError && + error.response.code === 400 && + error.response.body?.error === 'invalid_subject_token') + ) { + // re-throw; callers handle these specifically + throw error; + } + throw new Error('Internal Server Error'); + } +} diff --git a/packages/apps/shopify-app-express/src/index.ts b/packages/apps/shopify-app-express/src/index.ts index a7feaa22a8..9d9e5908e6 100644 --- a/packages/apps/shopify-app-express/src/index.ts +++ b/packages/apps/shopify-app-express/src/index.ts @@ -3,6 +3,7 @@ import '@shopify/shopify-api/adapters/node'; import { shopifyApi, ConfigParams as ApiConfigParams, + Session, Shopify, FeatureDeprecatedError, ShopifyRestResources, @@ -11,6 +12,8 @@ import {MemorySessionStorage} from '@shopify/shopify-app-session-storage-memory' import {SHOPIFY_EXPRESS_LIBRARY_VERSION} from './version'; import {AppConfigInterface, AppConfigParams} from './config-types'; +import {logDisabledFutureFlags} from './future/flags'; +import {IdempotentPromiseHandler} from './helpers/idempotent-promise-handler'; import { validateAuthenticatedSession, cspHeaders, @@ -64,6 +67,7 @@ export interface ShopifyApp { ensureInstalledOnShop: EnsureInstalledMiddleware; redirectToShopifyOrAppRoot: RedirectToShopifyOrAppRootMiddleware; redirectOutOfApp: RedirectOutOfAppFunction; + registerWebhooks: (params: {session: Session}) => Promise; } export function shopifyApp( @@ -74,6 +78,8 @@ export function shopifyApp( const api = shopifyApi(apiConfigWithDefaults(apiConfig)); const validatedConfig = validateAppConfig(appConfig, api); + logDisabledFutureFlags(validatedConfig, api.logger); + return { config: validatedConfig, api: api as Shopify< @@ -96,6 +102,9 @@ export function shopifyApp( config: validatedConfig, }), redirectOutOfApp: redirectOutOfApp({api, config: validatedConfig}), + registerWebhooks: async ({session}: {session: Session}) => { + await api.webhooks.register({session}); + }, }; } @@ -138,6 +147,9 @@ function validateAppConfig>( ...configWithoutSessionStorage, auth: config.auth, webhooks: config.webhooks, + future: config.future ?? {}, + hooks: config.hooks ?? {}, + idempotentPromiseHandler: new IdempotentPromiseHandler(), }; } diff --git a/packages/apps/shopify-app-express/src/middlewares/__tests__/ensure-installed-on-shop.test.ts b/packages/apps/shopify-app-express/src/middlewares/__tests__/ensure-installed-on-shop.test.ts index 4a7060a443..d1ff417e60 100644 --- a/packages/apps/shopify-app-express/src/middlewares/__tests__/ensure-installed-on-shop.test.ts +++ b/packages/apps/shopify-app-express/src/middlewares/__tests__/ensure-installed-on-shop.test.ts @@ -1,14 +1,17 @@ import request from 'supertest'; import express, {Express} from 'express'; import {ApiVersion, LogSeverity, Session} from '@shopify/shopify-api'; +import {MemorySessionStorage} from '@shopify/shopify-app-session-storage-memory'; import { createTestHmac, mockShopifyResponse, shopify, + testConfig, SHOPIFY_HOST, TEST_SHOP, } from '../../__tests__/test-helper'; +import {shopifyApp} from '../../index'; describe('ensureInstalledOnShop', () => { let app: Express; @@ -194,3 +197,115 @@ describe('ensureInstalledOnShop', () => { ); }); }); + +describe('ensureInstalledOnShop - token exchange flag', () => { + const encodedHost = Buffer.from(SHOPIFY_HOST, 'utf-8').toString('base64'); + + // Test 1: Flag OFF, no session → redirects to OAuth (regression guard) + it('flag off, no session → redirects to auth (regression guard)', async () => { + const app = express(); + app.use('/test', shopify.ensureInstalledOnShop()); + app.get('/test/shop', (_req, res) => res.json({})); + + const response = await request(app) + .get(`/test/shop?shop=${TEST_SHOP}&host=${encodedHost}&embedded=1`) + .expect(302); + + const location = new URL(response.header.location, 'https://example.com'); + expect(location.pathname).toBe(shopify.config.exitIframePath); + }); + + // Test 2: Flag ON, no session, embedded=1 → CSP headers + next(), no OAuth redirect + it('flag on, no session, embedded=1 → CSP headers + next()', async () => { + const shopifyWithFlag = shopifyApp({ + ...testConfig, + sessionStorage: new MemorySessionStorage(), + future: {unstable_newEmbeddedAuthStrategy: true}, + }); + + const app = express(); + app.use('/test', shopifyWithFlag.ensureInstalledOnShop()); + app.get('/test/shop', (_req, res) => res.json({ok: true})); + + const response = await request(app) + .get(`/test/shop?shop=${TEST_SHOP}&host=${encodedHost}&embedded=1`) + .expect(200); + + expect(response.body.ok).toBe(true); + expect(response.headers['content-security-policy']).toMatch(TEST_SHOP); + }); + + // Test 3: Flag ON, valid session exists, embedded=1 → CSP headers + next(), no GraphQL probe + it('flag on, valid session, embedded=1 → CSP headers + next(), no GraphQL probe', async () => { + const sessionStorage = new MemorySessionStorage(); + const shopifyWithFlag = shopifyApp({ + ...testConfig, + sessionStorage, + future: {unstable_newEmbeddedAuthStrategy: true}, + }); + + const scopes = shopifyWithFlag.api.config.scopes + ? shopifyWithFlag.api.config.scopes.toString() + : ''; + const existingSession = new Session({ + id: `offline_${TEST_SHOP}`, + shop: TEST_SHOP, + state: 'state', + isOnline: false, + scope: scopes, + accessToken: 'valid-token', + }); + await sessionStorage.storeSession(existingSession); + + const app = express(); + app.use('/test', shopifyWithFlag.ensureInstalledOnShop()); + app.get('/test/shop', (_req, res) => res.json({ok: true})); + + const response = await request(app) + .get(`/test/shop?shop=${TEST_SHOP}&host=${encodedHost}&embedded=1`) + .expect(200); + + expect(response.body.ok).toBe(true); + expect(response.headers['content-security-policy']).toMatch(TEST_SHOP); + // No GraphQL probe should have been made + expect(fetch).not.toHaveBeenCalled(); + }); + + // Test 4: Flag ON, no session, embedded absent → redirects to embedded Shopify URL + it('flag on, no session, embedded param absent → redirects to embedded Shopify URL', async () => { + const shopifyWithFlag = shopifyApp({ + ...testConfig, + sessionStorage: new MemorySessionStorage(), + future: {unstable_newEmbeddedAuthStrategy: true}, + }); + + const app = express(); + app.use('/test', shopifyWithFlag.ensureInstalledOnShop()); + app.get('/test/shop', (_req, res) => res.json({})); + + const response = await request(app) + .get(`/test/shop?shop=${TEST_SHOP}&host=${encodedHost}`) + .expect(302); + + const location = new URL(response.header.location, 'https://example.com'); + expect(location.hostname).toBe(SHOPIFY_HOST); + expect(location.pathname).toMatch( + new RegExp(`apps/${shopifyWithFlag.api.config.apiKey}`), + ); + }); + + // Test 5: Flag ON, no shop param → 400 + it('flag on, no shop param → 400', async () => { + const shopifyWithFlag = shopifyApp({ + ...testConfig, + sessionStorage: new MemorySessionStorage(), + future: {unstable_newEmbeddedAuthStrategy: true}, + }); + + const app = express(); + app.use('/test', shopifyWithFlag.ensureInstalledOnShop()); + app.get('/test/shop', (_req, res) => res.json({})); + + await request(app).get('/test/shop').expect(400); + }); +}); diff --git a/packages/apps/shopify-app-express/src/middlewares/__tests__/perform-token-exchange.test.ts b/packages/apps/shopify-app-express/src/middlewares/__tests__/perform-token-exchange.test.ts new file mode 100644 index 0000000000..3ef0cb6d53 --- /dev/null +++ b/packages/apps/shopify-app-express/src/middlewares/__tests__/perform-token-exchange.test.ts @@ -0,0 +1,288 @@ +import {createSecretKey} from 'crypto'; + +import request from 'supertest'; +import express, {Express} from 'express'; +import {Session} from '@shopify/shopify-api'; +import {SignJWT} from 'jose'; + +import { + shopify, + mockShopifyResponse, + mockShopifyResponses, +} from '../../__tests__/test-helper'; +import { + OFFLINE_TOKEN_EXCHANGE_RESPONSE, + ONLINE_TOKEN_EXCHANGE_RESPONSE, + EXPIRING_OFFLINE_TOKEN_EXCHANGE_RESPONSE, +} from '../../__tests__/integration/responses'; + +describe('performTokenExchange', () => { + let app: Express; + const shop = 'my-shop.myshopify.io'; + const sub = '1'; + + async function createSessionToken( + overrides: Record = {}, + ): Promise { + return new SignJWT({ + dummy: 'data', + aud: shopify.api.config.apiKey, + dest: `https://${shop}`, + sub, + ...overrides, + }) + .setProtectedHeader({alg: 'HS256'}) + .sign(createSecretKey(Buffer.from(shopify.api.config.apiSecretKey))); + } + + beforeEach(() => { + shopify.api.config.isEmbeddedApp = true; + shopify.config.future = {unstable_newEmbeddedAuthStrategy: true}; + shopify.config.useOnlineTokens = false; + + app = express(); + app.use('/test', shopify.validateAuthenticatedSession()); + app.get('/test/shop', async (_req, res) => { + res.json({session: res.locals.shopify?.session?.shop}); + }); + }); + + it('performs token exchange when no offline session exists', async () => { + const sessionToken = await createSessionToken(); + mockShopifyResponse(OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('performs token exchange when existing session is expired/inactive', async () => { + const sessionToken = await createSessionToken(); + const offlineId = shopify.api.session.getOfflineId(shop); + + const expiredSession = new Session({ + id: offlineId, + shop, + state: 'state', + isOnline: false, + accessToken: 'old-token', + expires: new Date(Date.now() - 60000), + }); + await shopify.config.sessionStorage.storeSession(expiredSession); + + mockShopifyResponse(OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('returns existing valid session without exchange', async () => { + const sessionToken = await createSessionToken(); + const offlineId = shopify.api.session.getOfflineId(shop); + + const scopes = shopify.api.config.scopes + ? shopify.api.config.scopes.toString() + : ''; + + const validSession = new Session({ + id: offlineId, + shop, + state: 'state', + isOnline: false, + scope: scopes, + accessToken: 'valid-token', + }); + await shopify.config.sessionStorage.storeSession(validSession); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('requests online token when useOnlineTokens is true', async () => { + shopify.config.useOnlineTokens = true; + const sessionToken = await createSessionToken(); + + // First response is offline exchange, second is online exchange + mockShopifyResponses( + [OFFLINE_TOKEN_EXCHANGE_RESPONSE], + [ONLINE_TOKEN_EXCHANGE_RESPONSE], + ); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('calls afterAuth hook after exchange (idempotent — only once per session token)', async () => { + const afterAuth = jest.fn(); + shopify.config.hooks = {afterAuth}; + + const sessionToken = await createSessionToken(); + mockShopifyResponse(OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(afterAuth).toHaveBeenCalledTimes(1); + expect(afterAuth).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.any(Session), + }), + ); + }); + + it('does NOT call afterAuth when session is already valid', async () => { + const afterAuth = jest.fn(); + shopify.config.hooks = {afterAuth}; + + const sessionToken = await createSessionToken(); + const offlineId = shopify.api.session.getOfflineId(shop); + + const scopes = shopify.api.config.scopes + ? shopify.api.config.scopes.toString() + : ''; + + const validSession = new Session({ + id: offlineId, + shop, + state: 'state', + isOnline: false, + scope: scopes, + accessToken: 'valid-token', + }); + await shopify.config.sessionStorage.storeSession(validSession); + + await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(afterAuth).not.toHaveBeenCalled(); + }); + + it('passes expiring: true to tokenExchange when expiringOfflineAccessTokens is set', async () => { + shopify.config.future = { + unstable_newEmbeddedAuthStrategy: true, + expiringOfflineAccessTokens: true, + }; + + const sessionToken = await createSessionToken(); + mockShopifyResponse(EXPIRING_OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('refreshes near-expiry session via re-exchange when expiringOfflineAccessTokens is set', async () => { + shopify.config.future = { + unstable_newEmbeddedAuthStrategy: true, + expiringOfflineAccessTokens: true, + }; + + const sessionToken = await createSessionToken(); + const offlineId = shopify.api.session.getOfflineId(shop); + + // Session that expires in 2 minutes (within the 5-minute threshold) + const nearExpirySession = new Session({ + id: offlineId, + shop, + state: 'state', + isOnline: false, + accessToken: 'about-to-expire-token', + expires: new Date(Date.now() + 2 * 60 * 1000), + }); + await shopify.config.sessionStorage.storeSession(nearExpirySession); + + mockShopifyResponse(EXPIRING_OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('returns 401 on InvalidJwtError', async () => { + const invalidJWT = await new SignJWT({ + dummy: 'data', + aud: shopify.api.config.apiKey, + dest: `https://${shop}`, + sub, + }) + .setProtectedHeader({alg: 'HS256'}) + .sign(createSecretKey(Buffer.from('wrong-secret-key-value'))); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${invalidJWT}`) + .expect(401); + + expect((response.error as any).text).toBeTruthy(); + }); + + it('returns 401 on 400 invalid_subject_token response', async () => { + const sessionToken = await createSessionToken(); + + mockShopifyResponse(JSON.stringify({error: 'invalid_subject_token'}), { + status: 400, + }); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(401); + + expect((response.error as any).text).toBeTruthy(); + }); + + it('returns 401 on Shopify 401 response', async () => { + const sessionToken = await createSessionToken(); + + mockShopifyResponse(JSON.stringify({errors: 'Unauthorized'}), { + status: 401, + }); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(401); + + expect((response.error as any).text).toBeTruthy(); + }); + + it('returns 500 on unexpected errors', async () => { + const sessionToken = await createSessionToken(); + + mockShopifyResponse(JSON.stringify({errors: 'Something went wrong'}), { + status: 503, + }); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(500); + + expect((response.error as any).text).toBe('Internal Server Error'); + }); +}); 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..4366d3f076 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 @@ -8,9 +8,11 @@ import {SignJWT} from 'jose'; import { createTestHmac, mockShopifyResponse, + mockShopifyResponses, shopify, SHOPIFY_HOST, } from '../../__tests__/test-helper'; +import {OFFLINE_TOKEN_EXCHANGE_RESPONSE} from '../../__tests__/integration/responses'; describe('validateAuthenticatedSession', () => { let app: Express; @@ -313,4 +315,69 @@ describe('validateAuthenticatedSession', () => { ).toBe(`${shopify.config.auth.path}?shop=my-shop.myshopify.io`); }); }); + + describe('with unstable_newEmbeddedAuthStrategy enabled', () => { + let validJWT: any; + + beforeEach(async () => { + shopify.api.config.isEmbeddedApp = true; + shopify.config.future = {unstable_newEmbeddedAuthStrategy: true}; + + app = express(); + app.use('/test', shopify.validateAuthenticatedSession()); + app.get('/test/shop', async (_req, res) => { + res.json({session: res.locals.shopify?.session?.shop}); + }); + + validJWT = await new SignJWT({ + dummy: 'data', + aud: shopify.api.config.apiKey, + dest: `https://${shop}`, + sub: '1', + }) + .setProtectedHeader({alg: 'HS256'}) + .sign(createSecretKey(Buffer.from(shopify.api.config.apiSecretKey))); + }); + + it('routes to performTokenExchange when Bearer token is present and flag is on', async () => { + mockShopifyResponse(OFFLINE_TOKEN_EXCHANGE_RESPONSE); + + const response = await request(app) + .get('/test/shop') + .set('Authorization', `Bearer ${validJWT}`) + .expect(200); + + expect(response.body.session).toBe(shop); + }); + + it('falls through to OAuth flow when flag is off (regression guard)', async () => { + shopify.config.future = {}; + + const scopes = shopify.api.config.scopes + ? shopify.api.config.scopes.toString() + : ''; + + session = new Session({ + id: sessionId, + shop, + state: '123-this-is-a-state', + isOnline: shopify.config.useOnlineTokens, + scope: scopes, + expires: undefined, + accessToken: 'totally-real-access-token', + }); + await shopify.config.sessionStorage.storeSession(session); + + // The OAuth path makes a GraphQL call to validate the access token + mockShopifyResponse({data: {shop: {name: shop}}}); + + const response = await request(app) + .get('/test/shop?shop=my-shop.myshopify.io') + .set('Authorization', `Bearer ${validJWT}`) + .expect(200); + + // When flag is off, the OAuth path is used and the session is set on res.locals + expect(response.body.session).toBe(shop); + }); + }); }); diff --git a/packages/apps/shopify-app-express/src/middlewares/ensure-installed-on-shop.ts b/packages/apps/shopify-app-express/src/middlewares/ensure-installed-on-shop.ts index ebe2508232..50a336589c 100644 --- a/packages/apps/shopify-app-express/src/middlewares/ensure-installed-on-shop.ts +++ b/packages/apps/shopify-app-express/src/middlewares/ensure-installed-on-shop.ts @@ -34,6 +34,22 @@ export function ensureInstalled({ return undefined; } + if (config.future.unstable_newEmbeddedAuthStrategy) { + config.logger.debug( + 'Token exchange strategy enabled, skipping session check', + {shop}, + ); + + if (api.config.isEmbeddedApp && req.query.embedded !== '1') { + await embedAppIntoShopify(api, config, req, res, shop); + return undefined; + } + + addCSPHeader(api, req, res); + config.logger.debug('App is ready to load', {shop}); + return next(); + } + config.logger.debug('Checking if shop has installed the app', {shop}); const sessionId = api.session.getOfflineId(shop); diff --git a/packages/apps/shopify-app-express/src/middlewares/perform-token-exchange.ts b/packages/apps/shopify-app-express/src/middlewares/perform-token-exchange.ts new file mode 100644 index 0000000000..20d5fae951 --- /dev/null +++ b/packages/apps/shopify-app-express/src/middlewares/perform-token-exchange.ts @@ -0,0 +1,146 @@ +import { + HttpResponseError, + InvalidJwtError, + RequestedTokenType, + Session, + Shopify, +} from '@shopify/shopify-api'; +import {Request, Response, NextFunction} from 'express'; + +import {AppConfigInterface} from '../config-types'; +import {WITHIN_MILLISECONDS_OF_EXPIRY} from '../helpers/ensure-offline-token-is-not-expired'; + +interface PerformTokenExchangeParams { + req: Request; + res: Response; + next: NextFunction; + api: Shopify; + config: AppConfigInterface; + sessionToken: string; +} + +export async function performTokenExchange({ + req: _req, + res, + next, + api, + config, + sessionToken, +}: PerformTokenExchangeParams): Promise { + const logger = config.logger; + + try { + const payload = await api.session.decodeSessionToken(sessionToken); + const shop = payload.dest.replace('https://', ''); + const sub = payload.sub; + + // Load the relevant session based on online/offline mode + let sessionId: string; + if (config.useOnlineTokens) { + sessionId = api.session.getJwtSessionId(shop, sub); + } else { + sessionId = api.session.getOfflineId(shop); + } + + let session: Session | undefined; + try { + session = await config.sessionStorage.loadSession(sessionId); + } catch (error) { + logger.error(`Error when loading session from storage: ${error}`); + res.status(500); + res.send(error.message); + return; + } + + // If session exists and is active (not within 5 min of expiry), use it + if (session && session.isActive(undefined, WITHIN_MILLISECONDS_OF_EXPIRY)) { + logger.debug('Request is valid, session is active', { + shop: session.shop, + }); + + res.locals.shopify = { + ...res.locals.shopify, + session, + }; + next(); + return; + } + + // No valid session — perform token exchange + logger.info('No valid session found', {shop}); + + // Always exchange offline first + logger.info('Requesting offline access token', {shop}); + const {session: offlineSession} = await api.auth.tokenExchange({ + sessionToken, + shop, + requestedTokenType: RequestedTokenType.OfflineAccessToken, + expiring: config.future?.expiringOfflineAccessTokens, + }); + await config.sessionStorage.storeSession(offlineSession); + + let newSession = offlineSession; + + // If using online tokens, also exchange for an online token + if (config.useOnlineTokens) { + logger.info('Requesting online access token', {shop}); + const {session: onlineSession} = await api.auth.tokenExchange({ + sessionToken, + shop, + requestedTokenType: RequestedTokenType.OnlineAccessToken, + expiring: config.future?.expiringOfflineAccessTokens, + }); + await config.sessionStorage.storeSession(onlineSession); + newSession = onlineSession; + } + + logger.debug('Request is valid, loaded session from session token', { + shop: newSession.shop, + isOnline: newSession.isOnline, + }); + + // Call afterAuth hook (idempotent — only once per session token) + try { + await config.idempotentPromiseHandler.handlePromise({ + promiseFunction: async () => { + await config.hooks?.afterAuth?.({session: newSession}); + }, + identifier: sessionToken, + }); + } catch (error) { + logger.error(`Error in afterAuth hook: ${error}`); + res.status(500); + res.send('Internal Server Error'); + return; + } + + res.locals.shopify = { + ...res.locals.shopify, + session: newSession, + }; + next(); + } catch (error) { + if ( + error instanceof InvalidJwtError || + (error instanceof HttpResponseError && + error.response.code === 400 && + error.response.body?.error === 'invalid_subject_token') + ) { + res.status(401); + res.send(error.message); + return; + } + + if (error instanceof HttpResponseError && error.response.code === 401) { + // Invalidate the access token by clearing it and re-storing + logger.debug('Responding to invalid access token'); + res.status(401); + res.send(error.message); + return; + } + + logger.error(`Unexpected error during token exchange: ${error}`); + res.status(500); + res.send('Internal Server Error'); + } +} 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..fa2d3eb610 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 @@ -4,9 +4,11 @@ import {Request, Response, NextFunction} from 'express'; import {redirectToAuth} from '../redirect-to-auth'; import {ApiAndConfigParams} from '../types'; import {redirectOutOfApp} from '../redirect-out-of-app'; +import {ensureOfflineTokenIsNotExpired} from '../helpers/ensure-offline-token-is-not-expired'; import {ValidateAuthenticatedSessionMiddleware} from './types'; import {hasValidAccessToken} from './has-valid-access-token'; +import {performTokenExchange} from './perform-token-exchange'; type validateAuthenticatedSessionParams = ApiAndConfigParams; @@ -18,6 +20,20 @@ export function validateAuthenticatedSession({ return async (req: Request, res: Response, next: NextFunction) => { config.logger.debug('Running validateAuthenticatedSession'); + // Token exchange path: when the flag is on and a Bearer token is present, + // bypass the OAuth flow entirely. + const bearerToken = req.headers.authorization?.match(/Bearer (.*)/)?.[1]; + if (config.future.unstable_newEmbeddedAuthStrategy && bearerToken) { + return performTokenExchange({ + req, + res, + next, + api, + config, + sessionToken: bearerToken, + }); + } + let sessionId: string | undefined; try { sessionId = await api.session.getCurrentId({ @@ -76,6 +92,11 @@ export function validateAuthenticatedSession({ shop: session.shop, }); + session = await ensureOfflineTokenIsNotExpired( + session, + api, + config, + ); res.locals.shopify = { ...res.locals.shopify, session,