Skip to content

Commit

Permalink
Merge pull request #387 from Shopify/enable_post_purchase_extensions
Browse files Browse the repository at this point in the history
Enable authenticate.public to handle post-purchase extension requests
  • Loading branch information
paulomarg authored Aug 16, 2023
2 parents 187da19 + 4f7fa36 commit 68f7c95
Show file tree
Hide file tree
Showing 10 changed files with 146 additions and 23 deletions.
5 changes: 5 additions & 0 deletions .changeset/young-islands-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-app-remix': patch
---

Enable `authenticate.public` to handle post-purchase extension requests by supporting extra CORS headers and fixing session token verification.
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ describe('authorize.admin', () => {
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Headers')).toBe(
'Authorization',
'Authorization, Content-Type',
);
expect(response.headers.get('Access-Control-Expose-Headers')).toBe(
REAUTH_URL_HEADER,
);
});

test('Does not adds CORS headers if OPTIONS request and origin is the app', async () => {
test('Does not add CORS headers if OPTIONS request and origin is the app', async () => {
// GIVEN
const shopify = shopifyApp(testConfig());

Expand All @@ -45,7 +45,7 @@ describe('authorize.admin', () => {
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).not.toBe('*');
expect(response.headers.get('Access-Control-Allow-Headers')).not.toBe(
'Authorization',
'Authorization, Content-Type',
);
expect(response.headers.get('Access-Control-Expose-Headers')).not.toBe(
REAUTH_URL_HEADER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface EnsureCORSFunction {
export function ensureCORSHeadersFactory(
params: BasicParams,
request: Request,
corsHeaders: string[] = [],
): EnsureCORSFunction {
const {logger, config} = params;

Expand All @@ -18,8 +19,17 @@ export function ensureCORSHeadersFactory(
'Request comes from a different origin, adding CORS headers',
);

const corsHeadersSet = new Set([
'Authorization',
'Content-Type',
...corsHeaders,
]);

response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Headers', 'Authorization');
response.headers.set(
'Access-Control-Allow-Headers',
[...corsHeadersSet].join(', '),
);
response.headers.set('Access-Control-Expose-Headers', REAUTH_URL_HEADER);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import {BasicParams} from '../../types';

import {ensureCORSHeadersFactory} from './ensure-cors-headers';

export function respondToOptionsRequest(params: BasicParams, request: Request) {
export function respondToOptionsRequest(
params: BasicParams,
request: Request,
corsHeaders?: string[],
) {
if (request.method === 'OPTIONS') {
const ensureCORSHeaders = ensureCORSHeadersFactory(params, request);
const ensureCORSHeaders = ensureCORSHeadersFactory(
params,
request,
corsHeaders,
);

throw ensureCORSHeaders(
new Response(null, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ import {JwtPayload} from '@shopify/shopify-api';

import type {BasicParams} from '../../types';

interface ValidateSessionTokenOptions {
checkAudience?: boolean;
}

export async function validateSessionToken(
{api, logger}: BasicParams,
token: string,
{checkAudience = true}: ValidateSessionTokenOptions = {},
): Promise<JwtPayload> {
logger.debug('Validating session token');

try {
const payload = await api.session.decodeSessionToken(token);
const payload = await api.session.decodeSessionToken(token, {
checkAudience,
});
logger.debug('Session token is valid', {
payload: JSON.stringify(payload),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import {
describe('JWT validation', () => {
it('returns token when successful', async () => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
const shopify = shopifyApp(testConfig());
const {token, payload} = getJwt();

// WHEN
Expand All @@ -26,10 +25,76 @@ describe('JWT validation', () => {
expect(sessionToken).toMatchObject(payload);
});

it('sets extra CORS allowed headers when requested from a different origin', async () => {
// GIVEN
const shopify = shopifyApp(testConfig());
const {token} = getJwt();

// WHEN
const {cors} = await shopify.authenticate.public(
new Request('https://some-other.origin', {
headers: {
Origin: 'https://some-other.origin',
Authorization: `Bearer ${token}`,
},
}),
{corsHeaders: ['Content-Type', 'X-Extra-Header']},
);
const response = cors(new Response());

// THEN
expect(response.headers.get('Access-Control-Allow-Headers')).toBe(
'Authorization, Content-Type, X-Extra-Header',
);
});

it('responds to preflight requests', async () => {
// GIVEN
const shopify = shopifyApp(testConfig());
const {token, payload} = getJwt();

// WHEN
const response = await getThrownResponse(
shopify.authenticate.public,
new Request(APP_URL, {
method: 'OPTIONS',
headers: {Authorization: `Bearer ${token}`},
}),
);

// THEN
expect(response.status).toBe(204);
});

it('responds to preflight requests from a different origin with extra CORS allowed headers', async () => {
// GIVEN
const shopify = shopifyApp(testConfig());
const {token} = getJwt();
const request = new Request('https://some-other.origin', {
method: 'OPTIONS',
headers: {
Origin: 'https://some-other.origin',
Authorization: `Bearer ${token}`,
},
});

// WHEN
const response = await getThrownResponse(
async (request) =>
shopify.authenticate.public(request, {corsHeaders: ['X-Extra-Header']}),
request,
);

// THEN
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Headers')).toBe(
'Authorization, Content-Type, X-Extra-Header',
);
});

it('throws a 401 on missing Authorization bearer token', async () => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
const shopify = shopifyApp(testConfig());

// WHEN
const response = await getThrownResponse(
Expand All @@ -43,8 +108,7 @@ describe('JWT validation', () => {

it('throws a 401 on invalid Authorization bearer token', async () => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
const shopify = shopifyApp(testConfig());

// WHEN
const response = await getThrownResponse(
Expand All @@ -60,8 +124,7 @@ describe('JWT validation', () => {

it('rejects bot requests', async () => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
const shopify = shopifyApp(testConfig());

// WHEN
const response = await getThrownResponse(
Expand Down
13 changes: 9 additions & 4 deletions packages/shopify-app-remix/src/auth/public/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import {
validateSessionToken,
} from '../helpers';

import type {PublicContext} from './types';
import type {AuthenticatePublicOptions, PublicContext} from './types';

export function authenticatePublicFactory(params: BasicParams) {
return async function authenticatePublic(
request: Request,
options: AuthenticatePublicOptions = {},
): Promise<PublicContext> {
const {logger} = params;

const corsHeaders = options.corsHeaders ?? [];

rejectBotRequest(params, request);
respondToOptionsRequest(params, request);
respondToOptionsRequest(params, request, corsHeaders);

const sessionTokenHeader = getSessionTokenHeader(request);

Expand All @@ -31,8 +34,10 @@ export function authenticatePublicFactory(params: BasicParams) {
}

return {
sessionToken: await validateSessionToken(params, sessionTokenHeader),
cors: ensureCORSHeadersFactory(params, request),
sessionToken: await validateSessionToken(params, sessionTokenHeader, {
checkAudience: false,
}),
cors: ensureCORSHeadersFactory(params, request, corsHeaders),
};
};
}
10 changes: 8 additions & 2 deletions packages/shopify-app-remix/src/auth/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import {JwtPayload} from '@shopify/shopify-api';

import {EnsureCORSFunction} from '../helpers/ensure-cors-headers';

export interface AuthenticatePublicOptions {
corsHeaders?: string[];
}

/**
* Authenticated Context for a public request
*/
Expand Down Expand Up @@ -42,9 +46,11 @@ export interface PublicContext {
*
* export const loader = async ({ request }: LoaderArgs) => {
* const { sessionToken, cors } = await authenticate.public(
* request
* request,
* { corsHeaders: ["X-My-Custom-Header"] }
* );
* return cors(json(await getWidgets({shop: sessionToken.dest})));
* const widgets = await getWidgets({shop: sessionToken.dest});
* return cors(json(widgets));
* };
* ```
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/shopify-app-remix/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {SessionStorage} from '@shopify/shopify-app-session-storage';

import type {AppConfig, AppConfigArg} from './config-types';
import type {AdminContext} from './auth/admin/types';
import type {PublicContext} from './auth/public/types';
import type {
AuthenticatePublicOptions,
PublicContext,
} from './auth/public/types';
import type {
RegisterWebhooksOptions,
WebhookContext,
Expand Down Expand Up @@ -69,7 +72,10 @@ type AuthenticateAdmin<
Resources extends ShopifyRestResources = ShopifyRestResources,
> = (request: Request) => Promise<AdminContext<Config, Resources>>;

type AuthenticatePublic = (request: Request) => Promise<PublicContext>;
type AuthenticatePublic = (
request: Request,
options?: AuthenticatePublicOptions,
) => Promise<PublicContext>;

type AuthenticateWebhook<
Resources extends ShopifyRestResources = ShopifyRestResources,
Expand Down
13 changes: 13 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3067,6 +3067,19 @@
tslib "^2.0.3"
uuid "^9.0.0"

"@shopify/shopify-api@^7.5.1":
version "7.5.1"
resolved "https://registry.yarnpkg.com/@shopify/shopify-api/-/shopify-api-7.5.1.tgz#d3c69c0b68082885bcb3ffe4a5d9cbdfb3687e46"
integrity sha512-zydzwkDPIyXiDcfFCW/R14m36mQkpAP4GthenrEHdx0B/yyfmfCnB6sB4KjWwtd+Y+gojx+PE/lTPCPdPCd2Kg==
dependencies:
"@shopify/network" "^3.2.1"
compare-versions "^5.0.3"
isbot "^3.6.10"
jose "^4.9.1"
node-fetch "^2.6.1"
tslib "^2.0.3"
uuid "^9.0.0"

"@shopify/typescript-configs@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@shopify/typescript-configs/-/typescript-configs-5.1.0.tgz#f6e8fdd3291bf0a406578b2c6eb21f8c542d3c0a"
Expand Down

0 comments on commit 68f7c95

Please sign in to comment.