Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pull changes from main #568

Closed
wants to merge 30 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f00205b
add unstable_newEmbeddedAuthStrateg future flag
rachel-carvalho Nov 29, 2023
7f2737c
add token exchange strategy
rachel-carvalho Nov 29, 2023
8df3491
Enable token exchange strategy when future flag is enabled
rezaansyed Nov 29, 2023
a69e426
setup auth code flow tests with future flag off
rachel-carvalho Nov 30, 2023
f0a8362
add tests to token exchange strategy
rachel-carvalho Nov 30, 2023
b366dda
move tests that redirect when no session is found into auth code flow…
rachel-carvalho Nov 30, 2023
7a562f1
throw 500 for most token exchange errors and complete code coverage
rezaansyed Dec 1, 2023
ed4b46e
Fix logging
rezaansyed Dec 1, 2023
ba932e4
Update types to account for unstable_tokenExchange and unstable_newEm…
rezaansyed Dec 1, 2023
7980ab5
Add changeset
rezaansyed Dec 1, 2023
2811004
Update future flag description
rezaansyed Dec 4, 2023
ceebd32
Log throttling errors on webhook registration
rezaansyed Dec 4, 2023
711b6d3
Refactor to address review comments
rezaansyed Dec 5, 2023
942ed9b
Introduce idempotent promise handler
rezaansyed Dec 7, 2023
d777a9d
Merge pull request #522 from Shopify/idempotent-promise-handler
rezaansyed Dec 7, 2023
27f3be8
Redirect to install from app home page if future flag is on and app i…
zzooeeyy Dec 7, 2023
5b3d8fd
move invalid access token redirect to auth tests to auth code flow st…
rachel-carvalho Dec 6, 2023
470890b
Merge remote-tracking branch 'origin/feature/token-exchange' into exp…
rachel-carvalho Dec 11, 2023
c679502
delegate handling invalid access token errors to auth strategy
rachel-carvalho Dec 6, 2023
34e1752
Add error handling for token exchange strategy on invalid API requests
rezaansyed Dec 11, 2023
c09d26e
Redirect to new admin URL instead of legacy
zzooeeyy Dec 12, 2023
92968c8
Merge pull request #527 from Shopify/zoey/redirect-to-install
rachel-carvalho Dec 12, 2023
c607e00
Clear stale identifiers in IdempotentPromiseHandler
rezaansyed Dec 8, 2023
f7cab50
Merge pull request #528 from Shopify/idempotent-handler-clear-identif…
rezaansyed Dec 12, 2023
746e7aa
Move SessionContext to authenticate as it is only used there
rezaansyed Dec 12, 2023
158ef77
Inject error handling strategy on after auth API calls
rezaansyed Dec 12, 2023
3fc0466
Pass in handleClientError method from the strategy
rezaansyed Dec 12, 2023
d2a7613
Merge pull request #532 from Shopify/add-handle-client-error-token-ex…
rezaansyed Dec 13, 2023
d5d2b5b
Merge changes from main and fix conflicts
zzooeeyy Jan 10, 2024
fac204d
Fix token-exchange/admin-client test
zzooeeyy Jan 10, 2024
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
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 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};
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import {Session, ShopifyRestResources} from '@shopify/shopify-api';

import type {BasicParams} from '../../../types';
import {AdminApiContext, adminClientFactory} from '../../../clients/admin';

import {handleClientErrorFactory} from './handle-client-error';
import {
AdminApiContext,
HandleAdminClientError,
adminClientFactory,
} from '../../../clients/admin';

export function createAdminApiContext<
Resources extends ShopifyRestResources = ShopifyRestResources,
>(
request: Request,
session: Session,
params: BasicParams,
handleClientError: HandleAdminClientError,
): AdminApiContext<Resources> {
return adminClientFactory<Resources>({
session,
params,
handleClientError: handleClientErrorFactory({
request,
}),
handleClientError,
});
}
Loading
Loading