Implementing Shopify OAuth2 with Cogntio User Pool, Amplify and Lambda

A couple of years ago I started building Shopify apps using serverless architectures. My first attempts were React applications using REST API built using the AWS API Gateway, Lambda and DynamoDB but I quickly moved to GraphQL once I discovered how powerful it was.

When I saw AppSync and Amplify in the Re:invent 2017 videos I knew immediately that I wanted to use them. Unfortunately it became obvious very quickly that this wasn't going to be smooth sailing. AppSync required all requests to be authenticated but none of the supported authentication methods worked with Shopify OAuth2. In fact most OAuth2 providers aren't supported unless they also implement OpenID Connect which rules out providers like Twitter.

Before we go any further it's important to understand that Shopify requires that applications authenticate users using OAuth2 with a couple of twists to complicate the process.

  1. You need to use the Authorization Code Grant flow.
  2. When Shopify sends a visitor to your application they will append the paramter shop with .myshopify.com domain for the store and the applications is expected to automatically log the user in.
  3. Each store has it's own OAuth URLs

Here's how I worked around these problems to get OAuth2 working with Cognito User Pools and Amplify so I could use AppSync.

The process starts at the login page for my React app which checks for the shop parameter when it was loaded. If that parameter is found an auth begin API is called with the shop parameter and a callback URL for the React app. This API is implemented using the API Gateway (because it allows unauthenticated requests) and Lambda.

The auth begin Lambda performs some validation before generating the Shopify OAuth login URL. Part of that URL is a nonce that needs to be checked when the user returns from Shopify. To allow this to be checked the Lambda also generate a session token which is a signed JSON Web Token (JWT) with a short expiry time containing the value of the nonce. Both the Shopify OAuth URL and the session are sent back to the React app which stores the session token in cookie storage (local storage didn't work with Safari if this was an embedded Shopify app) and the user was redirected to the Shopify OAuth login URL.

beginAuth.ts

import "source-map-support/register";

import { APIGatewayEvent, Context, ProxyResult } from "aws-lambda";
import * as querystring from "querystring";

import { badRequest, internalError, ok } from "./lib/http";
import { createJWT } from "./lib/jwt";
import { withAsyncMonitoring } from "./lib/monitoring";
import { getRandomString } from "./lib/string";

export async function handle(event: APIGatewayEvent): Promise<ProxyResult> {
  try {
    const shopifyApiKey = process.env.SHOPIFY_API_KEY;
    const shopifyScope = process.env.SHOPIFY_SCOPE;

    if (!shopifyApiKey) {
      throw new Error("SHOPIFY_API_KEY environment variable not set");
    }

    if (!shopifyScope) {
      throw new Error("SHOPIFY_SCOPE environment variable not set");
    }

    if (!event.queryStringParameters) {
      return badRequest("No query string paramters found");
    }

    const { "callback-url": callbackUrl, "per-user": perUser, shop } = event.queryStringParameters;

    if (!callbackUrl) {
      return badRequest("'callback-url' parameter missing");
    }

    if (!shop) {
      return badRequest("'shop' parameter missing");
    }

    if (!shop.match(/[a-z0-9][a-z0-9\-]*\.myshopify\.com/i)) {
      return badRequest("'shop' parameter must end with .myshopify.com and may only contain a-z, 0-9, - and .");
    }

    const now = new Date();
    const nonce = getRandomString();

    // Build our authUrl
    const eNonce = querystring.escape(nonce);
    const eClientId = querystring.escape(shopifyApiKey);
    const eScope = querystring.escape(shopifyScope.replace(":", ","));
    const eCallbackUrl = querystring.escape(callbackUrl);
    const option = perUser === "true" ? "&option=per-user" : "";
    // tslint:disable-next-line:max-line-length
    const authUrl = `https://${shop}/admin/oauth/authorize?client_id=${eClientId}&scope=${eScope}&redirect_uri=${eCallbackUrl}&state=${eNonce}${option}`;

    // Return the authURL
    return ok({
      authUrl,
      token: createJWT(shop, nonce, now, 600),
    });
  } catch (e) {
    console.log("Error", e);
    return internalError();
  }
}

Once the user authenticates to shopify they are sent to the callback URL which reloads the React app. This time the query string parameters and the session token from cookie storage are sent to an auth complete API that is also implemented using the API Gateway and Lmbda. After validating the input and checking the nonce in the callback URL matches the one from the session token the code is exchanged with Shopify.

Once the code has been exchange successfully the Lambda proceeds to create a user in the Cognito User Pool, if they don't exist, and then generate a new JWT with a short expiry date as a time limited password. If you would prefer a true one time password then you could store it in DynamoDB and remove it once it's been used. This new token is returned by the API.

authComplete.ts

import "source-map-support/register";

import { APIGatewayEvent, Context, ProxyResult } from "aws-lambda";
import * as AWS from "aws-sdk";
import * as crypto from "crypto";
import * as jwt from "jsonwebtoken";
import fetch, { Request, RequestInit, Response } from "node-fetch";

import { IStoredShopDataCreate } from "./interfaces";
import { badRequest, internalError, ok } from "./lib/http";
import { createJWT } from "./lib/jwt";
import { IShop } from "./lib/shopify";
import { getRandomString } from "./lib/string";

// The shape of the token exchange response from Shopify
interface IShopifyTokenResponse {
  error?: string;
  errors?: string;
  error_description?: string;
  access_token?: string;
  scope?: string;
}

export async function handler(event: APIGatewayEvent): Promise<ProxyResult> {
  try {
    if (!event.body) {
      return badRequest("body is empty");
    }

    const json = JSON.parse(event.body);
    const { token, params } = json;

    if (!token) {
      return badRequest("'token' is missing");
    }

    if (!params) {
      return badRequest("'params' is missing");
    }

    const { code, shop: shopDomain } = params;

    if (!validateNonce(token, params) || !validateShopDomain(shopDomain) || !validateHMAC(params)) {
      return badRequest("Invalid 'token'");
    }

    const resp = await exchangeToken(shopDomain, code, fetchFn);
    const accessToken = resp.access_token;
    if (accessToken === undefined) {
      console.log('resp["access_token"] is undefined');
      throw new Error('resp["access_token"] is undefined');
    }

    const userId = await createUser(shopDomain);

    const now = new Date();
    const nonce = getRandomString();

    // Return the authURL
    return ok({
      chargeAuthorizationUrl: null,
      token: createJWT(userId, nonce, now, 600),
    });
  } catch (e) {
    console.log("Error", e);
    return internalError();
  }
}

// Validate the nonce against the token
function validateNonce(token: string, params: any): boolean {
  const jwtSecret = process.env.JWT_SECRET;
  if (!jwtSecret) {
    throw new Error("JWT_SECRET environment variable is not set");
  }

  try {
    jwt.verify(token, jwtSecret, {
      clockTolerance: 600,
      issuer: process.env.JWT_ISS || "isa",
      jwtid: params.state,
      subject: params.shop,
    });
    return true;
  } catch (err) {
    console.log("Error verifying nonce", err);
    return false;
  }
}

// Check that the shopDomain is a valid myshop.com domain. This is required by Shopify
function validateShopDomain(shopDomain: string): boolean {
  if (shopDomain.match(/^[a-z][a-z0-9\-]*\.myshopify\.com$/i) === null) {
    console.log("Shop validation failed", shopDomain);
    return false;
  }

  return true;
}

// Validate the HMAC parameter
function validateHMAC(params: any): boolean {
  const shopifyApiSecret = process.env.SHOPIFY_API_SECRET;
  if (!shopifyApiSecret) {
    throw new Error("SHOPIFY_API_SECRET environment variable not set");
  }

  const p = [];
  for (const k in params) {
    if (k !== "hmac") {
      k.replace("%", "%25");
      p.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k].toString()));
    }
  }
  const message = p.sort().join("&");

  const digest = crypto.createHmac("SHA256", shopifyApiSecret).update(message).digest("hex");

  return digest === params.hmac;
}

// Exchange the temporary code the permanent API token
async function exchangeToken(
  shop: string,
  code: string,
  fetchFn: (url: string | Request, init?: RequestInit) => Promise<Response>
): Promise<IShopifyTokenResponse> {
  const body = JSON.stringify({
    client_id: process.env.SHOPIFY_API_KEY,
    client_secret: process.env.SHOPIFY_API_SECRET,
    code,
  });

  const url = `https://${shop}/admin/oauth/access_token`;

  const res = await fetchFn(url, {
    body,
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    method: "POST",
  });

  const json = await res.json();
  console.log("Shopify Token Exchange Response", json);
  if ("error_description" in json || "error" in json || "errors" in json) {
    throw new Error(json.error_description || json.error || json.errors);
  }
  return json;
}

// Create the user if they don't already exist
async function createUser(shopDomain: string): Promise<string> {
  const identityProvider = new AWS.CognitoIdentityServiceProvider({
    apiVersion: "2016-04-18",
  });

  const userPoolId = process.env.USER_POOL_ID;
  if (!userPoolId) {
    throw new Error("USER_POOL_ID environment variable not set");
  }

  const email = shopDomain.replace(".myshopify.com", "@myshopify.com");

  const userParams: AWS.CognitoIdentityServiceProvider.AdminCreateUserRequest = {
    MessageAction: "SUPPRESS",
    UserAttributes: [
      { Name: "email", Value: email },
      { Name: "name", Value: shopDomain },
      { Name: "website", Value: shopDomain },
    ],
    UserPoolId: userPoolId,
    Username: email,
  };
  console.log("Admin Create User", userParams);

  try {
    const result = await identityProvider.adminCreateUser(userParams).promise();
    if (result.User && result.User.Username) {
      return result.User.Username;
    }

    throw Error("No username!!");
  } catch (err) {
    if (err.code === "UsernameExistsException") {
      const user = await identityProvider
        .adminGetUser({
          UserPoolId: userPoolId,
          Username: email,
        })
        .promise();

      return user.Username;
    }

    throw err;
  }
}

Using this new token our React application can perform passwordless login to the Cognito Users Pool. It's a feature that was recently added to Amplify.

const body = {
  params,
  token,
};
const result = await fetch(url, {
  body: JSON.stringify(body),
  cache: "no-cache",
  method: "POST",
});
if (result.ok) {
  const json = await result.json();
  const userName = params.shop.replace(".myshopify.com", "@myshopify.com");
  const user = await Auth.signIn(userName);
  if (user.challengeName === "CUSTOM_CHALLENGE" && user.challengeParam.distraction === "Yes") {
    await Auth.sendCustomChallengeAnswer(user, json.token);
  }
  // Success!!
} else {
  // Something went wrong :(
}

To make it work our Cognito User Pool has been setup to allow CUSTOM_AUTH_FLOW_ONLY. This allows us to customise the login flow using three Lambda functions so that when the user provides their username they are challenged for the once time password instead of their real password.

createAuthChallenge.ts

import "source-map-support/register";

import { CognitoUserPoolEvent, Context } from "aws-lambda";

export async function handler(event: CognitoUserPoolEvent): Promise<CognitoUserPoolEvent> {
  if (!event.request.session || event.request.session.length === 0) {
    // For the first challenge ask for a JWT token
    event.response.publicChallengeParameters = {
      distraction: "Yes",
    };
    event.response.privateChallengeParameters = {
      distraction: "Yes",
    };
    // @ts-ignore
    event.response.challengeMetadata = "JWT";
  }

  console.log("Response", event.response);

  return event;
}

defineAuthChallenge.ts

import "source-map-support/register";

import { CognitoUserPoolEvent, Context } from "aws-lambda";

export async function handler(event: CognitoUserPoolEvent): Promise<CognitoUserPoolEvent> {
  if (!event.request.session || event.request.session.length === 0) {
    // If we don't have a session or it is empty then issue a CUSTOM_CHALLENGE
    event.response.challengeName = "CUSTOM_CHALLENGE";
    event.response.failAuthentication = false;
    event.response.issueTokens = false;
  } else if (event.request.session.length === 1 && event.request.session[0].challengeResult === true) {
    // If we passed the CUSTOM_CHALLENGE then issue token
    event.response.failAuthentication = false;
    event.response.issueTokens = true;
  } else {
    // Something is wrong. Fail authentication
    event.response.failAuthentication = true;
    event.response.issueTokens = false;
  }

  return event;
}

verifyAuthChallengeResponse

import "source-map-support/register";

import { CognitoUserPoolEvent, Context } from "aws-lambda";
import * as jwt from "jsonwebtoken";

export async function handler(event: CognitoUserPoolEvent): Promise<CognitoUserPoolEvent> {
  const jwtSecret = process.env.JWT_SECRET;
  // @ts-ignore
  const challengeAnswer: string = event.request.challengeAnswer;
  if (!jwtSecret || !challengeAnswer) {
    console.log("No JWT_SECRET or challengeAnswer");
    event.response.answerCorrect = false;
  } else {
    try {
      jwt.verify(challengeAnswer, jwtSecret, {
        clockTolerance: 600,
        issuer: process.env.JWT_ISS || "isa",
        subject: event.userName,
      });
      event.response.answerCorrect = true;
    } catch (err) {
      console.log("Error verifying nonce", err);
      event.response.answerCorrect = false;
    }
  }

  return event;
}