Skip to content

Commit

Permalink
Merge pull request #523 from Shopify/feature/token-exchange
Browse files Browse the repository at this point in the history
Merge full token exchange and manage install feature into main
  • Loading branch information
rezaansyed authored Jan 17, 2024
2 parents 00defb8 + 4683a0e commit 977d8ff
Show file tree
Hide file tree
Showing 39 changed files with 1,882 additions and 667 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-horses-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-app-remix': minor
---

Add new embedded authorization strategy relying on Shopify managed install and OAuth token exchange
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const API_KEY = 'testApiKey';
export const APP_URL = 'https://my-test-app.myshopify.io';
export const SHOPIFY_HOST = 'totally-real-host.myshopify.io';
export const BASE64_HOST = Buffer.from(SHOPIFY_HOST).toString('base64');
export const TEST_SHOP = 'test-shop.myshopify.com';
export const TEST_SHOP_NAME = 'test-shop';
export const TEST_SHOP = `${TEST_SHOP_NAME}.myshopify.com`;
export const GRAPHQL_URL = `https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/graphql.json`;
export const USER_ID = 12345;
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {TEST_SHOP, USER_ID} from './const';

export async function setUpValidSession(
sessionStorage: SessionStorage,
isOnline = false,
sessionParams?: Partial<Session>,
): Promise<Session> {
const overrides: Partial<Session> = {};
let id = `offline_${TEST_SHOP}`;
if (isOnline) {
if (sessionParams?.isOnline) {
id = `${TEST_SHOP}_${USER_ID}`;
// Expires one day from now
overrides.expires = new Date(Date.now() + 1000 * 3600 * 24);
overrides.expires =
sessionParams.expires || new Date(Date.now() + 1000 * 3600 * 24);
overrides.onlineAccessInfo = {
associated_user_scope: 'testScope',
expires_in: 3600 * 24,
Expand All @@ -32,7 +33,7 @@ export async function setUpValidSession(
const session = new Session({
id,
shop: TEST_SHOP,
isOnline,
isOnline: Boolean(sessionParams?.isOnline),
state: 'test',
accessToken: 'totally_real_token',
scope: 'testScope',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {API_KEY, API_SECRET_KEY, APP_URL} from './const';
const TEST_FUTURE_FLAGS: Required<{[key in keyof FutureFlags]: true}> = {
v3_authenticatePublic: true,
v3_webhookAdminContext: true,
unstable_newEmbeddedAuthStrategy: true,
} as const;

const TEST_CONFIG = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
APP_URL,
BASE64_HOST,
TEST_SHOP,
expectExitIframeRedirect,
getJwt,
getThrownResponse,
setUpValidSession,
Expand All @@ -21,7 +20,6 @@ import {
expectAdminApiClient,
} from '../../../__test-helpers';
import {shopifyApp} from '../../..';
import {REAUTH_URL_HEADER} from '../../const';
import {AdminApiContext} from '../../../clients';

describe('admin.authenticate context', () => {
Expand Down Expand Up @@ -102,25 +100,6 @@ describe('admin.authenticate context', () => {
])(
'$testGroup re-authentication',
({testGroup: _testGroup, mockRequest, action}) => {
it('redirects to auth when request receives a 401 response and not embedded', async () => {
// GIVEN
const {admin, session} = await setUpNonEmbeddedFlow();
const requestMock = await mockRequest(401);

// WHEN
const response = await getThrownResponse(
async () => action(admin, session),
requestMock,
);

// THEN
expect(response.status).toEqual(302);

const {hostname, pathname} = new URL(response.headers.get('Location')!);
expect(hostname).toEqual(TEST_SHOP);
expect(pathname).toEqual('/admin/oauth/authorize');
});

it('throws a response when request receives a non-401 response and not embedded', async () => {
// GIVEN
const {admin, session} = await setUpNonEmbeddedFlow();
Expand All @@ -135,43 +114,6 @@ describe('admin.authenticate context', () => {
// THEN
expect(response.status).toEqual(403);
});

it('redirects to exit iframe when request receives a 401 response and embedded', async () => {
// GIVEN
const {admin, session} = await setUpEmbeddedFlow();
const requestMock = await mockRequest(401);

// WHEN
const response = await getThrownResponse(
async () => action(admin, session),
requestMock,
);

// THEN
expectExitIframeRedirect(response);
});

it('returns app bridge redirection headers when request receives a 401 response on fetch requests', async () => {
// GIVEN
const {admin, session} = await setUpFetchFlow();
const requestMock = await mockRequest(401);

// WHEN
const response = await getThrownResponse(
async () => action(admin, session),
requestMock,
);

// THEN
expect(response.status).toEqual(401);

const {origin, pathname, searchParams} = new URL(
response.headers.get(REAUTH_URL_HEADER)!,
);
expect(origin).toEqual(APP_URL);
expect(pathname).toEqual('/auth');
expect(searchParams.get('shop')).toEqual(TEST_SHOP);
});
},
);
});
Expand All @@ -192,21 +134,6 @@ async function setUpEmbeddedFlow() {
};
}

async function setUpFetchFlow() {
const shopify = shopifyApp(testConfig({restResources}));
await setUpValidSession(shopify.sessionStorage);

const {token} = getJwt();
const request = new Request(APP_URL, {
headers: {Authorization: `Bearer ${token}`},
});

return {
shopify,
...(await shopify.authenticate.admin(request)),
};
}

async function setUpNonEmbeddedFlow() {
const shopify = shopifyApp(testConfig({restResources, isEmbeddedApp: false}));
const session = await setUpValidSession(shopify.sessionStorage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,24 @@ describe('authorize.admin doc request path', () => {
// THEN
expect(response.status).toBe(400);
});

it("redirects to the embedded app URL if the app isn't embedded yet", async () => {
// GIVEN
const config = testConfig();
const shopify = shopifyApp(config);
await setUpValidSession(shopify.sessionStorage);

// WHEN
const response = await getThrownResponse(
shopify.authenticate.admin,
new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`),
);

// THEN
const {hostname, pathname} = new URL(response.headers.get('location')!);

expect(response.status).toBe(302);
expect(hostname).toBe(SHOPIFY_HOST);
expect(pathname).toBe(`/apps/${API_KEY}`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,59 +30,6 @@ describe('authorize.session token header path', () => {
// THEN
expect(response.status).toBe(401);
});

describe.each([true, false])('when isOnline: %s', (isOnline) => {
it(`returns app bridge redirection headers if there is no session`, async () => {
// GIVEN
const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline}));

// WHEN
const {token} = getJwt();
const response = await getThrownResponse(
shopify.authenticate.admin,
new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, {
headers: {Authorization: `Bearer ${token}`},
}),
);

// THEN
const {origin, pathname, searchParams} = new URL(
response.headers.get(REAUTH_URL_HEADER)!,
);

expect(response.status).toBe(401);
expect(origin).toBe(APP_URL);
expect(pathname).toBe('/auth');
expect(searchParams.get('shop')).toBe(TEST_SHOP);
});

it(`returns app bridge redirection headers if the session is no longer valid`, async () => {
// GIVEN
const shopify = shopifyApp(
testConfig({useOnlineTokens: isOnline, scopes: ['otherTestScope']}),
);
// The session scopes don't match the configured scopes, so it needs to be reset
await setUpValidSession(shopify.sessionStorage, isOnline);

// WHEN
const {token} = getJwt();
const response = await getThrownResponse(
shopify.authenticate.admin,
new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, {
headers: {Authorization: `Bearer ${token}`},
}),
);

// THEN
const {origin, pathname, searchParams} = new URL(
response.headers.get(REAUTH_URL_HEADER)!,
);
expect(response.status).toBe(401);
expect(origin).toBe(APP_URL);
expect(pathname).toBe('/auth');
expect(searchParams.get('shop')).toBe(TEST_SHOP);
});
});
});

describe.each([true, false])(
Expand All @@ -92,10 +39,9 @@ describe('authorize.session token header path', () => {
// GIVEN
const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline}));

const testSession = await setUpValidSession(
shopify.sessionStorage,
const testSession = await setUpValidSession(shopify.sessionStorage, {
isOnline,
);
});

// WHEN
const {token, payload} = getJwt();
Expand All @@ -120,7 +66,9 @@ describe('authorize.session token header path', () => {
let testSession: Session;
testSession = await setUpValidSession(shopify.sessionStorage);
if (isOnline) {
testSession = await setUpValidSession(shopify.sessionStorage, true);
testSession = await setUpValidSession(shopify.sessionStorage, {
isOnline: true,
});
}

// WHEN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ import {
renderAppBridge,
validateShopAndHostParams,
} from './helpers';
import {AuthorizationStrategy, SessionTokenContext} from './strategies/types';
import {AuthorizationStrategy} from './strategies/types';

export interface SessionTokenContext {
shop: string;
sessionId?: string;
sessionToken?: string;
payload?: JwtPayload;
}

interface AuthStrategyParams extends BasicParams {
strategy: AuthorizationStrategy;
Expand Down Expand Up @@ -68,16 +75,17 @@ export function authStrategyFactory<
function createContext(
request: Request,
session: Session,
authStrategy: AuthorizationStrategy,
sessionToken?: JwtPayload,
): AdminContext<ConfigArg, Resources> {
const context:
| EmbeddedAdminContext<ConfigArg, Resources>
| NonEmbeddedAdminContext<ConfigArg, Resources> = {
admin: createAdminApiContext<Resources>(request, session, {
api,
logger,
config,
}),
admin: createAdminApiContext<Resources>(
session,
params,
authStrategy.handleClientError(request),
),
billing: {
require: requireBillingFactory(params, request, session),
request: requestBillingFactory(params, request, session),
Expand Down Expand Up @@ -116,28 +124,26 @@ export function authStrategyFactory<

logger.info('Authenticating admin request');

const {payload, shop, sessionId} = await getSessionTokenContext(
params,
request,
);
const {payload, shop, sessionId, sessionToken} =
await getSessionTokenContext(params, request);

logger.debug('Loading session from storage', {sessionId});
const existingSession = sessionId
? await config.sessionStorage.loadSession(sessionId)
: undefined;

const session = await strategy.authenticate(
request,
existingSession,
const session = await strategy.authenticate(request, {
session: existingSession,
sessionToken,
shop,
);
});

logger.debug('Request is valid, loaded session from session token', {
shop: session.shop,
isOnline: session.isOnline,
});

return createContext(request, session, payload);
return createContext(request, session, strategy, payload);
} catch (errorOrResponse) {
if (errorOrResponse instanceof Response) {
ensureCORSHeadersFactory(params, request)(errorOrResponse);
Expand All @@ -159,10 +165,10 @@ async function getSessionTokenContext(
const sessionToken = (headerSessionToken || searchParamSessionToken)!;

logger.debug('Attempting to authenticate session token', {
sessionToken: {
sessionToken: JSON.stringify({
header: headerSessionToken,
search: searchParamSessionToken,
},
}),
});

if (config.isEmbeddedApp) {
Expand All @@ -178,7 +184,7 @@ async function getSessionTokenContext(
? api.session.getJwtSessionId(shop, payload.sub)
: api.session.getOfflineId(shop);

return {shop, payload, sessionId};
return {shop, payload, sessionId, sessionToken};
}

const url = new URL(request.url);
Expand All @@ -189,5 +195,5 @@ async function getSessionTokenContext(
rawRequest: request,
});

return {shop, sessionId, payload: undefined};
return {shop, sessionId, payload: undefined, sessionToken};
}
Loading

0 comments on commit 977d8ff

Please sign in to comment.