UNPKG

@shopify/shopify-app-remix

Version:

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

234 lines (231 loc) 9.74 kB
import { CookieNotFound, InvalidHmacError, InvalidOAuthError, HttpResponseError, GraphqlQueryError } from '@shopify/shopify-api'; import { beginAuth } from '../helpers/begin-auth.mjs'; import { redirectToShopifyOrAppRoot } from '../helpers/redirect-to-shopify-or-app-root.mjs'; import '@remix-run/server-runtime'; import { handleClientErrorFactory } from '../helpers/handle-client-error.mjs'; import { redirectToAuthPage } from '../helpers/redirect-to-auth-page.mjs'; import { redirectWithExitIframe } from '../helpers/redirect-with-exitiframe.mjs'; import { getSessionTokenHeader } from '../../helpers/get-session-token-header.mjs'; import { triggerAfterAuthHook } from '../helpers/trigger-after-auth-hook.mjs'; import { validateShopAndHostParams } from '../helpers/validate-shop-and-host-params.mjs'; import 'isbot'; class AuthCodeFlowStrategy { api; config; logger; constructor({ api, config, logger }) { this.api = api; this.config = config; this.logger = logger; } async respondToOAuthRequests(request) { 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); } } async authenticate(request, sessionContext) { const { api, config, logger } = this; const { shop, session } = sessionContext; if (!session) { logger.debug('No session found, redirecting to OAuth', { shop }); await redirectToAuthPage({ config, 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, api }, request, shop); } logger.debug('Found a valid session', { shop }); return session; } handleClientError(request) { const { api, config, logger } = this; return handleClientErrorFactory({ request, onError: async ({ session, error }) => { if (error.response.code === 401) { throw await redirectToAuthPage({ api, config}, request, session.shop); } }, }); } async ensureInstalledOnShop(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}, request, shop); } else { throw await beginAuth({ api, config}, 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); } } } async handleAuthBeginRequest(request, shop) { 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}, request, shop); } else { throw await beginAuth({ api, config}, request, false, shop); } } async handleAuthCallbackRequest(request, shop) { 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); } } async getOfflineSession(request) { const offlineId = await this.getOfflineSessionId(request); return this.config.sessionStorage.loadSession(offlineId); } async hasValidOfflineId(request) { return Boolean(await this.getOfflineSessionId(request)); } async getOfflineSessionId(request) { 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 }); } async testSession(session) { const { api } = this; const client = new api.clients.Graphql({ session, }); await client.request(`#graphql query shopifyAppShopName { shop { name } } `); } async oauthCallbackError(error, request, shop) { 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', }); } async handleInvalidOfflineSession(error, request, shop) { 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}, 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 = { 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', }); } } } export { AuthCodeFlowStrategy }; //# sourceMappingURL=auth-code-flow.mjs.map