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
17 changes: 17 additions & 0 deletions .changeset/render-app-bridge-missing-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@shopify/shopify-app-react-router': patch
---

Fixed an issue where embedded apps would incorrectly show the login page when
`shop` or `host` query params were missing from a document request (e.g. after
SPA navigation followed by a full page reload during local development).

Instead of redirecting to the login path, the server now renders a minimal App
Bridge page. App Bridge detects it is still embedded in the Shopify admin iframe,
retrieves the session token from the parent frame, and re-authenticates
seamlessly — no user interaction required.

This is a non-breaking change. The previous login redirect was effectively dead
code for embedded apps (`isEmbeddedApp` is always `true` for apps using this
library; the `ShopifyAdmin` distribution is excluded earlier in the pipeline).
No public APIs are added, removed, or changed.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ export * from './const';
export * from './expect-admin-api-client';
export * from './expect-document-request-headers';
export * from './expect-exit-iframe';
export * from './expect-login-redirect';
export * from './expect-storefront-api-client';
export * from './get-hmac';
export * from './get-jwt';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {shopifyApp} from '../../..';
import {APP_BRIDGE_URL} from '../../const';
import {
API_KEY,
APP_URL,
Expand All @@ -8,7 +9,6 @@ import {
getThrownResponse,
setUpValidSession,
testConfig,
expectLoginRedirect,
} from '../../../__test-helpers';

describe('authorize.admin doc request path', () => {
Expand All @@ -18,22 +18,32 @@ describe('authorize.admin doc request path', () => {
{shop: TEST_SHOP, host: 'invalid-domain.test'},
{shop: undefined, host: BASE64_HOST},
{shop: 'invalid', host: BASE64_HOST},
])('throws when %s', async ({shop, host}) => {
// GIVEN
const shopify = shopifyApp(testConfig());
const searchParams = new URLSearchParams();
if (shop) searchParams.set('shop', shop);
if (host) searchParams.set('host', host);

// WHEN
const response = await getThrownResponse(
shopify.authenticate.admin,
new Request(`${APP_URL}?${searchParams.toString()}`),
);

// THEN
expectLoginRedirect(response);
});
])(
'renders App Bridge when embedded app has missing or invalid params: %s',
async ({shop, host}) => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
const searchParams = new URLSearchParams();
if (shop) searchParams.set('shop', shop);
if (host) searchParams.set('host', host);

// WHEN
const response = await getThrownResponse(
shopify.authenticate.admin,
new Request(`${APP_URL}?${searchParams.toString()}`),
);

// THEN
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe(
'text/html;charset=utf-8',
);
expect((await response.text()).trim()).toBe(
`<script data-api-key="${config.apiKey}" src="${APP_BRIDGE_URL}"></script>`,
);
},
);

it('throws an error if the request URL is the login path', async () => {
// GIVEN
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {redirect} from 'react-router';

import {BasicParams, AppDistribution} from '../../../types';

import {renderAppBridge} from './render-app-bridge';

export function validateShopAndHostParams(
params: BasicParams,
request: Request,
Expand All @@ -12,24 +12,24 @@ export function validateShopAndHostParams(
const url = new URL(request.url);
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
if (!shop) {
logger.debug('Missing or invalid shop, redirecting to login path', {
logger.debug('Missing or invalid shop, rendering App Bridge', {
shop,
});
throw redirectToLoginPath(request, params);
throw renderAppBridgeOrError(request, params);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we distinguish shop missing from invalid here?

For example I believe further down the call stack with this flow we will set the CSP headers with that invalid shop.

renderAppBridge() re-reads the raw query param and passes it into header construction

}

const host = api.utils.sanitizeHost(url.searchParams.get('host')!);
if (!host) {
logger.debug('Invalid host, redirecting to login path', {
logger.debug('Invalid host, rendering App Bridge', {
shop,
host: url.searchParams.get('host'),
});
throw redirectToLoginPath(request, params);
throw renderAppBridgeOrError(request, params);
}
}
}

function redirectToLoginPath(request: Request, params: BasicParams): never {
function renderAppBridgeOrError(request: Request, params: BasicParams): never {
const {config, logger} = params;

const {pathname} = new URL(request.url);
Expand All @@ -42,5 +42,8 @@ function redirectToLoginPath(request: Request, params: BasicParams): never {
throw new Response(message, {status: 500});
}

throw redirect(config.auth.loginPath);
logger.debug(
'Missing shop or host params, rendering App Bridge to retrieve session',
);
throw renderAppBridge(params, request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ describe('Reject bot requests', () => {
);

// THEN
expect(response.status).toBe(302);
// The request passes the bot check and proceeds to auth validation.
// For embedded apps without shop/host params, this renders the App Bridge
// page (200) to retrieve the session token from the parent frame.
expect(response.status).toBe(200);
});
});
Loading