UNPKG

@shopify/shopify-app-remix

Version:

Shopify Remix - to simplify the building of Shopify Apps with Remix

333 lines (279 loc) 9.49 kB
import { CookieNotFound, GraphqlQueryError, HttpResponseError, InvalidHmacError, InvalidOAuthError, Session, Shopify, } from '@shopify/shopify-api'; import type {BasicParams} from '../../../types'; import { beginAuth, handleClientErrorFactory, redirectToAuthPage, redirectToShopifyOrAppRoot, redirectWithExitIframe, triggerAfterAuthHook, validateShopAndHostParams, } from '../helpers'; import {AppConfig, AppConfigArg} from '../../../config-types'; import {getSessionTokenHeader} from '../../helpers'; import {HandleAdminClientError} from '../../../clients'; import type { ApiConfigWithFutureFlags, ApiFutureFlags, } from '../../../future/flags'; import {AuthorizationStrategy, SessionContext, OnErrorOptions} from './types'; export class AuthCodeFlowStrategy< Config extends AppConfigArg, > implements AuthorizationStrategy { protected api: Shopify< ApiConfigWithFutureFlags<Config['future']>, ApiFutureFlags<Config['future']> >; protected config: AppConfig; protected logger: Shopify['logger']; public constructor({api, config, logger}: BasicParams) { this.api = api; this.config = config; this.logger = logger; } public async respondToOAuthRequests(request: Request): Promise<void | never> { const {api, config} = this; const url = new URL(request.url); const isAuthRequest = url.pathname === config.auth.path; const isAuthCallbackRequest = url.pathname === config.auth.callbackPath; if (isAuthRequest || isAuthCallbackRequest) { const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!); if (!shop) throw new Response('Shop param is invalid', {status: 400}); if (isAuthRequest) { throw await this.handleAuthBeginRequest(request, shop); } else { throw await this.handleAuthCallbackRequest(request, shop); } } if (!getSessionTokenHeader(request)) { // This is a document request that doesn't contain a session token. We check if the app is installed. // If the app isn't installed, we initiate the OAuth auth code flow. // Requests with a header can only happen after the app is installed. await this.ensureInstalledOnShop(request); } } public async authenticate( request: Request, sessionContext: SessionContext, ): Promise<Session | never> { const {api, config, logger} = this; const {shop, session} = sessionContext; if (!session) { logger.debug('No session found, redirecting to OAuth', {shop}); await redirectToAuthPage({config, logger, api}, request, shop); } else if (!session.isActive(config.scopes)) { logger.debug( 'Found a session, but it has expired, redirecting to OAuth', {shop}, ); await redirectToAuthPage({config, logger, api}, request, shop); } logger.debug('Found a valid session', {shop}); return session!; } public handleClientError(request: Request): HandleAdminClientError { const {api, config, logger} = this; return handleClientErrorFactory({ request, onError: async ({session, error}: OnErrorOptions) => { if (error.response.code === 401) { throw await redirectToAuthPage( {api, config, logger}, request, session.shop, ); } }, }); } private async ensureInstalledOnShop(request: Request) { const {api, config, logger} = this; validateShopAndHostParams({api, config, logger}, request); const url = new URL(request.url); let shop = url.searchParams.get('shop'); // Ensure app is installed logger.debug('Ensuring app is installed on shop', {shop}); if (!(await this.hasValidOfflineId(request))) { logger.info("Could not find a shop, can't authenticate request"); throw new Response(undefined, { status: 400, statusText: 'Bad Request', }); } const offlineSession = await this.getOfflineSession(request); const isEmbedded = url.searchParams.get('embedded') === '1'; if (!offlineSession) { logger.info("Shop hasn't installed app yet, redirecting to OAuth", { shop, }); if (isEmbedded) { redirectWithExitIframe({api, config, logger}, request, shop!); } else { throw await beginAuth({api, config, logger}, request, false, shop!); } } shop = shop || offlineSession.shop; if (config.isEmbeddedApp && !isEmbedded) { try { logger.debug('Ensuring offline session is valid before embedding', { shop, }); await this.testSession(offlineSession); logger.debug('Offline session is still valid, embedding app', {shop}); } catch (error) { await this.handleInvalidOfflineSession(error, request, shop); } } } private async handleAuthBeginRequest( request: Request, shop: string, ): Promise<never> { const {api, config, logger} = this; logger.info('Handling OAuth begin request', {shop}); // If we're loading from an iframe, we need to break out of it if ( config.isEmbeddedApp && request.headers.get('Sec-Fetch-Dest') === 'iframe' ) { logger.debug('Auth request in iframe detected, exiting iframe', {shop}); throw redirectWithExitIframe({api, config, logger}, request, shop); } else { throw await beginAuth({api, config, logger}, request, false, shop); } } private async handleAuthCallbackRequest( request: Request, shop: string, ): Promise<never> { const {api, config, logger} = this; logger.info('Handling OAuth callback request', {shop}); try { const {session, headers: responseHeaders} = await api.auth.callback({ rawRequest: request, expiring: config.future.expiringOfflineAccessTokens, }); await config.sessionStorage!.storeSession(session); if (config.useOnlineTokens && !session.isOnline) { logger.info('Requesting online access token for offline session', { shop, }); await beginAuth({api, config, logger}, request, true, shop); } logger.debug('Request is valid, loaded session from OAuth callback', { shop: session.shop, isOnline: session.isOnline, }); await triggerAfterAuthHook({api, config, logger}, session, request, this); throw await redirectToShopifyOrAppRoot( request, {api, config, logger}, responseHeaders, ); } catch (error) { if (error instanceof Response) throw error; throw await this.oauthCallbackError(error, request, shop); } } private async getOfflineSession( request: Request, ): Promise<Session | undefined> { const offlineId = await this.getOfflineSessionId(request); return this.config.sessionStorage!.loadSession(offlineId!); } private async hasValidOfflineId(request: Request) { return Boolean(await this.getOfflineSessionId(request)); } private async getOfflineSessionId( request: Request, ): Promise<string | undefined> { const {api} = this; const url = new URL(request.url); const shop = url.searchParams.get('shop'); return shop ? api.session.getOfflineId(shop) : api.session.getCurrentId({isOnline: false, rawRequest: request}); } private async testSession(session: Session): Promise<void> { const {api} = this; const client = new api.clients.Graphql({ session, }); await client.request(`#graphql query shopifyAppShopName { shop { name } } `); } private async oauthCallbackError( error: Error, request: Request, shop: string, ) { const {logger} = this; logger.error('Error during OAuth callback', {shop, error: error.message}); if (error instanceof CookieNotFound) { return this.handleAuthBeginRequest(request, shop); } if ( error instanceof InvalidHmacError || error instanceof InvalidOAuthError ) { return new Response(undefined, { status: 400, statusText: 'Invalid OAuth Request', }); } return new Response(undefined, { status: 500, statusText: 'Internal Server Error', }); } private async handleInvalidOfflineSession( error: Error, request: Request, shop: string, ) { const {api, logger, config} = this; if (error instanceof HttpResponseError) { if (error.response.code === 401) { logger.info('Shop session is no longer valid, redirecting to OAuth', { shop, }); throw await beginAuth({api, config, logger}, request, false, shop); } else { const message = JSON.stringify(error.response.body, null, 2); logger.error(`Unexpected error during session validation: ${message}`, { shop, }); throw new Response(undefined, { status: error.response.code, statusText: error.response.statusText, }); } } else if (error instanceof GraphqlQueryError) { const context: Record<string, string> = {shop}; if (error.response) { context.response = JSON.stringify(error.body); } logger.error( `Unexpected error during session validation: ${error.message}`, context, ); throw new Response(undefined, { status: 500, statusText: 'Internal Server Error', }); } } }