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

Token exchange prototype #448

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
da35ae3
Add EmbeddedAuthStrategy class
rezaansyed Sep 7, 2023
5daf9cc
Update package.json files
rezaansyed Sep 7, 2023
3600727
Make EmbeddedAuthStrategy the default
rezaansyed Sep 7, 2023
0b3e469
Redirect to bounce page when id token in URL param is invalid
rezaansyed Sep 7, 2023
0fcb5ed
[temporary] add local-build.sh file
rachel-carvalho Sep 7, 2023
c560cb7
Redirect to bounce page if token exchange fails on initial load
rezaansyed Sep 8, 2023
1559b05
delete access token & send header back when core returns 401
rachel-carvalho Sep 11, 2023
e3e3d2c
reload page when access token is invalid and is not xhr request
rachel-carvalho Sep 11, 2023
46f7bd6
redirect to oauth/install page when scopes changed, re-exchanging tok…
rachel-carvalho Sep 11, 2023
747ab0c
support login page
rachel-carvalho Sep 18, 2023
e305d65
use different auth strategy based on isEmbedded
rachel-carvalho Sep 18, 2023
20583fb
Add auth-helpers for common functions across strategies
rezaansyed Sep 18, 2023
d7a726e
move remaining helper methods
rachel-carvalho Sep 18, 2023
eca3f10
yarn lock
rachel-carvalho Sep 18, 2023
5d18064
some cleanup
rachel-carvalho Sep 19, 2023
be1d84f
delete id token when redirecting to bounce page
rachel-carvalho Sep 19, 2023
1c9b3c1
return 401 status and invalid session header when session token is in…
rachel-carvalho Sep 20, 2023
8a10227
comment out exit iframe to redirect to install when scopes do not match
rachel-carvalho Oct 5, 2023
2eead05
Override to use local app bridge
kbav Oct 5, 2023
a959711
fix build after refactors from main
rachel-carvalho Oct 19, 2023
0a18507
Merge changes
rezaansyed Oct 6, 2023
2398cbf
Add future flag support
rezaansyed Nov 8, 2023
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
56 changes: 56 additions & 0 deletions local-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
cd ~/src/github.com/Shopify/shopify-app-js

# clean shopify-api-js's build
cd ../shopify-api-js/packages/shopify-api
yarn token-exchange-clean
cd ../../../shopify-app-js

# unlink local version of shopify-api-js (or true, it's ok if it wasn't linked before)
yarn unlink "@shopify/shopify-api" || true

# reinstall dependencies (needs force after unlink)
yarn install --force

# unlink app-remix package
if [ -d "packages/shopify-app-remix/build/cjs" ]; then
cd packages/shopify-app-remix/build/cjs
yarn unlink || true
cd ../../../../
fi

# unlink react, polaris and react run packages
cd node_modules/react
yarn unlink || true
cd ../@shopify/polaris
yarn unlink || true
cd ../../@remix-run/react
yarn unlink || true
cd ../../../

# clean build
yarn clean

# re-build shopify-api-js
cd ../shopify-api-js/packages/shopify-api
yarn token-exchange-build
cd ../../../shopify-app-js

# re-link shopify-api-js
yarn link "@shopify/shopify-api"

# rebuild
yarn build

# link shopify-app-remix from the build folder
cd packages/shopify-app-remix/build/cjs
yarn link
cd ../../../../

# link react, polaris and react run packages so that app uses a single version of them (they're direct app dependencies)
cd node_modules/react
yarn link
cd ../@shopify/polaris
yarn link
cd ../../@remix-run/react
yarn link
cd ../../../
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"test": "loom test",
"lint": "loom lint",
"release": "loom build && changeset publish",
"clean": "rimraf ./packages/*/build .loom"
"clean": "rimraf ./packages/*/build .loom",
"token-exchange-build": "./local-build.sh"
},
"devDependencies": {
"@changesets/cli": "^2.26.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('<AppProvider />', () => {

// THEN
expect(component).toContainReactComponent('script', {
src: 'https://cdn.shopify.com/shopifycloud/app-bridge.js',
src: '/dev/app-bridge.js',
});
expect(component).toContainReactHtml('Hello world');
});
Expand Down
3 changes: 1 addition & 2 deletions packages/shopify-app-remix/src/react/const.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export const APP_BRIDGE_URL =
'https://cdn.shopify.com/shopifycloud/app-bridge.js';
export const APP_BRIDGE_URL = '/dev/app-bridge.js';
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {redirect} from '@remix-run/server-runtime';
import {
CookieNotFound,
GraphqlQueryError,
Expand All @@ -10,8 +9,8 @@ import {
Shopify,
ShopifyRestResources,
} from '@shopify/shopify-api';
import {MockApiConfig} from 'src/server/shopify-app';

import {AdminApiContext, adminClientFactory} from '../../clients/admin';
import type {BasicParams} from '../../types';
import type {AppConfig, AppConfigArg} from '../../config-types';
import {
Expand All @@ -22,25 +21,23 @@ import {
ensureCORSHeadersFactory,
} from '../helpers';

import type {BillingContext} from './billing/types';
import {
cancelBillingFactory,
requestBillingFactory,
requireBillingFactory,
} from './billing';
import type {
AdminContext,
EmbeddedAdminContext,
NonEmbeddedAdminContext,
} from './types';
import type {AdminContext} from './types';
import {
beginAuth,
handleClientErrorFactory,
redirectFactory,
redirectToAuthPage,
redirectWithExitIframe,
renderAppBridge,
} from './helpers';
import {
createAdminApiContext,
createApiContext,
ensureAppIsEmbeddedIfRequired,
ensureValidShopParam,
redirectToBouncePage,
redirectToShopifyOrAppRoot,
validateUrlParams,
} from './helpers/auth-helpers';

interface SessionContext {
session: Session;
Expand All @@ -53,11 +50,11 @@ export class AuthStrategy<
Config extends AppConfigArg,
Resources extends ShopifyRestResources = ShopifyRestResources,
> {
protected api: Shopify;
protected api: Shopify<MockApiConfig<Config['future']>>;
protected config: AppConfig;
protected logger: Shopify['logger'];

public constructor({api, config, logger}: BasicParams) {
public constructor({api, config, logger}: BasicParams<Config['future']>) {
this.api = api;
this.config = config;
this.logger = logger;
Expand All @@ -67,6 +64,7 @@ export class AuthStrategy<
request: Request,
): Promise<AdminContext<Config, Resources>> {
const {api, logger, config} = this;
logger.info('shopify-app-remix is local');

rejectBotRequest({api, logger, config}, request);
respondToOptionsRequest({api, logger, config}, request);
Expand All @@ -84,24 +82,13 @@ export class AuthStrategy<
throw errorOrResponse;
}

const context:
| EmbeddedAdminContext<Config, Resources>
| NonEmbeddedAdminContext<Config, Resources> = {
admin: this.createAdminApiContext(request, sessionContext.session),
billing: this.createBillingContext(request, sessionContext.session),
session: sessionContext.session,
return createApiContext<Config, Resources>(
{api, logger, config},
request,
sessionContext,
cors,
};

if (config.isEmbeddedApp) {
return {
...context,
sessionToken: sessionContext!.token!,
redirect: redirectFactory({api, config, logger}, request),
} as AdminContext<Config, Resources>;
} else {
return context as AdminContext<Config, Resources>;
}
handleClientErrorFactory,
);
}

private async authenticateAndGetSessionContext(
Expand Down Expand Up @@ -141,9 +128,9 @@ export class AuthStrategy<

return this.validateAuthenticatedSession(request, sessionToken);
} else {
await this.validateUrlParams(request);
await validateUrlParams(request, {api, logger, config});
await this.ensureInstalledOnShop(request);
await this.ensureAppIsEmbeddedIfRequired(request);
await ensureAppIsEmbeddedIfRequired(request, {api, logger, config});
await this.ensureSessionTokenSearchParamIfRequired(request);

return this.ensureSessionExists(request);
Expand All @@ -155,7 +142,7 @@ export class AuthStrategy<

logger.info('Handling OAuth begin request');

const shop = this.ensureValidShopParam(request);
const shop = ensureValidShopParam(request, {api, logger, config});

logger.debug('OAuth request contained valid shop', {shop});

Expand All @@ -176,7 +163,7 @@ export class AuthStrategy<

logger.info('Handling OAuth callback request');

const shop = this.ensureValidShopParam(request);
const shop = ensureValidShopParam(request, {api, logger, config});

try {
const {session, headers: responseHeaders} = await api.auth.callback({
Expand All @@ -194,11 +181,24 @@ export class AuthStrategy<
logger.info('Running afterAuth hook');
await config.hooks.afterAuth({
session,
admin: this.createAdminApiContext(request, session),
admin: createAdminApiContext<Resources>(
request,
session,
handleClientErrorFactory,
{
api,
logger,
config,
},
),
});
}

throw await this.redirectToShopifyOrAppRoot(request, responseHeaders);
throw await redirectToShopifyOrAppRoot(
request,
{api, config, logger},
responseHeaders,
);
} catch (error) {
if (error instanceof Response) {
throw error;
Expand All @@ -225,29 +225,6 @@ export class AuthStrategy<
}
}

private async validateUrlParams(request: Request) {
const {api, config, logger} = this;

if (config.isEmbeddedApp) {
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', {
shop,
});
throw redirect(config.auth.loginPath);
}

const host = api.utils.sanitizeHost(url.searchParams.get('host')!);
if (!host) {
logger.debug('Invalid host, redirecting to login path', {
host: url.searchParams.get('host'),
});
throw redirect(config.auth.loginPath);
}
}
}

private async ensureInstalledOnShop(request: Request) {
const {api, config, logger} = this;
const url = new URL(request.url);
Expand Down Expand Up @@ -351,34 +328,8 @@ export class AuthStrategy<
});
}

private ensureValidShopParam(request: Request): string {
const url = new URL(request.url);
const {api} = this;
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);

if (!shop) {
throw new Response('Shop param is invalid', {
status: 400,
});
}

return shop;
}

private async ensureAppIsEmbeddedIfRequired(request: Request) {
const {api, logger} = this;
const url = new URL(request.url);

const shop = url.searchParams.get('shop')!;

if (api.config.isEmbeddedApp && url.searchParams.get('embedded') !== '1') {
logger.debug('App is not embedded, redirecting to Shopify', {shop});
await this.redirectToShopifyOrAppRoot(request);
}
}

private async ensureSessionTokenSearchParamIfRequired(request: Request) {
const {api, logger} = this;
const {api, config, logger} = this;
const url = new URL(request.url);

const shop = url.searchParams.get('shop')!;
Expand All @@ -389,7 +340,7 @@ export class AuthStrategy<
'Missing session token in search params, going to bounce page',
{shop},
);
this.redirectToBouncePage(url);
redirectToBouncePage(url, {api, logger, config});
}
}

Expand Down Expand Up @@ -474,67 +425,4 @@ export class AuthStrategy<

return session!;
}

private async redirectToShopifyOrAppRoot(
request: Request,
responseHeaders?: Headers,
): Promise<never> {
const {api} = this;
const url = new URL(request.url);

const host = api.utils.sanitizeHost(url.searchParams.get('host')!)!;
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!)!;

const redirectUrl = api.config.isEmbeddedApp
? await api.auth.getEmbeddedAppUrl({rawRequest: request})
: `/?shop=${shop}&host=${encodeURIComponent(host)}`;

throw redirect(redirectUrl, {headers: responseHeaders});
}

private redirectToBouncePage(url: URL): never {
const {config} = this;

// Make sure we always point to the configured app URL so it also works behind reverse proxies (that alter the Host
// header).
url.searchParams.set(
'shopify-reload',
`${config.appUrl}${url.pathname}${url.search}`,
);

// eslint-disable-next-line no-warning-comments
// TODO Make sure this works on chrome without a tunnel (weird HTTPS redirect issue)
// https://github.com/orgs/Shopify/projects/6899/views/1?pane=issue&itemId=28376650
throw redirect(`${config.auth.patchSessionTokenPath}${url.search}`);
}

private createBillingContext(
request: Request,
session: Session,
): BillingContext<Config> {
const {api, logger, config} = this;

return {
require: requireBillingFactory({api, logger, config}, request, session),
request: requestBillingFactory({api, logger, config}, request, session),
cancel: cancelBillingFactory({api, logger, config}, request, session),
};
}

private createAdminApiContext(
request: Request,
session: Session,
): AdminApiContext<Resources> {
return adminClientFactory<Resources>({
session,
params: {
api: this.api,
config: this.config,
logger: this.logger,
},
handleClientError: handleClientErrorFactory({
request,
}),
});
}
}
Loading
Loading