Using AWS CloudFront Functions for distribution of a Shopify Admin App
8.2.2022Recently I built an app for the e-commerce platform where merchants could schedule actions that should be executed at a fixed time in the future. An action could be something like changing the theme of the store or adjusting a product's status. Such an "admin app" is embedded directly in the merchant's admin interface via an iFrame. I decided to use AWS as infrastructure and use the single-page-webapp pattern. This implies a split of the deliveries of the webapp. There is a static part that contains the HTML, the JS and the CSS of the frontend. And additionally, there is a REST API responsible for all of the business logic, but this is not the segment I want to cover in this post. AWS provides with CloudFront a convenient feature to provide static assets over its Content-Delivery-Network. As a developer, I only have to upload all static resources to an S3 Bucket. When a user later wants to access these Resources over HTTP(s) the request goes to the next of the 310 CloudFront point-of-presence. CloudFront then checks its Cach, on a hit the resource is returned immediately, if not, the resource is fetched from the S3 Bucket, put in the cache and returned afterwards. For most use-cases, this already works good enough, but embedded Shopify admin apps need a few further treatments:
- The path and the querystring are signed by Shopify using an HMAC with the apps client secret as an authenticity check.
- The URL has to contain the shop id in the querystring. If this is missing a 404 should be returned.
- If the user navigates the first time to the app (indicated by a missing querystring parameter), he should be redirected to the OAuth authorization page.
- The app should always be displayed in the iFrame of the admin interface. If the user opens the app in its own window the header
Sec-Fetch-Dest
isdocument
. In this case, he should be redirected to the admin interface. - Because I used React Router I had to serve the entry point HTML file on all paths. More on that problem here
- The app should only be embeddable in the admin interface of the shop given in the querystring. Therefore the
Content-Security-Policy
Header has to contain the shop-id in theframe-ancestors
.
Developing Functions
To support the described requirements I used CloudFront Functions. With CloudFront Functions its possible to alter requests and responses before respectively after the request is processed by the CloudFront Cache. In contrast to an AWS Lambda, CloudFront Functions are executed at the edge of every point-of-presence and not only in one region to decrease latency. But there are also some Pitfalls like only a restricted subset of ECMAScript is allowed. Also, it is not possible to access external resources like a Database or a REST Endpoint. And the Function has no access to the request or the response body. But this is completely fine for my use case.
I had to write two functions for my requirements, the first function is executed on the request, she checks the request and optionally redirects the users, the second function works with the response, it adds the Content-Security-Policy
Header.
I published the source code of both functions on Gist
Compiling the Functions
As described earlier AWS CloudFront only supports a subset of current ECMAScript. Also, it isn't possible to specify any environment variables, that contain, in this case, the Shopify client id and secret. To solve all of the problems I used Babel to inject the environment variables directly into the source code, to transcompile the not supported language constructs to an older ECMAScript version and to minify the output. The following shows the used .babelrc.js
config file.
const { scopes } = require("../../frontend/scopes.json");
module.exports = {
plugins: [
[
"transform-define",
{
"process.env.SHOPIFY_API_KEY": process.env.SHOPIFY_API_KEY,
"process.env.SHOPIFY_API_SECRET": process.env.SHOPIFY_API_SECRET,
"process.env.BASE_URL": process.env.BASE_URL,
"process.env.SCOPES": scopes,
},
],
],
presets: [
"minify",
[
"@babel/preset-env",
{
targets: {
firefox: "20",
},
},
],
],
comments: false,
};
Deployment
To enabled automated AWS deployments I really started to like the Infrastructure-as-Code toolkit AWS CDK. In AWS CDK a whole AWS landscape is described in a high-level programming language like Go or Typescript. Individual Cloud services are instantiated as so-called constructs. A construct could also be referenced by another construct. During "synthesis" of the IaC script, the constructs are compiled into an AWS Cloudformation template, that could be deployed to AWS later on.
In the following code snippet, I defined three such Constructs, first, the CloudFrontWebDistribution
that represents the CloudFront Distribution of the Frontend. As the source, I use an S3 Bucket I defined earlier in the script. Then I defined the routing behaviour of the distribution. All assets (e.g. JS files, terms of service HTML) are delivered out of the /assets
path, they don't require special treatment and could therefore be delivered directly out of the S3 Bucket. All other paths should be processed by the two CloudFront Functions described earlier. Both of them are defined as separate constructs and are passed to the outer CloudFront Distribution construct.
const frontendDistribution = new CloudFrontWebDistribution(
this,
"frontend-distribution",
{
originConfigs: [
{
s3OriginSource: {
s3BucketSource: frontendBucket,
originAccessIdentity: originAccessIdentity,
},
behaviors: [
{
pathPattern: "/assets/*", // Directly distribute out of S3
},
{
isDefaultBehavior: true, // All other paths, beside /assets/*
functionAssociations: [
{
function: new EdgeFunction(this, "cf-request-function", {
code: FunctionCode.fromFile({
filePath: path.join(
__dirname,
"cf_functions/dist/request.js"
),
}),
}),
eventType: FunctionEventType.VIEWER_REQUEST,
},
{
function: new EdgeFunction(this, "cf-response-function", {
code: FunctionCode.fromFile({
filePath: path.join(__dirname, "cf_functions/response.js"),
}),
}),
eventType: FunctionEventType.VIEWER_RESPONSE,
},
],
},
],
},
],
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
viewerCertificate: ViewerCertificate.fromAcmCertificate(
certificateUSEast1,
{
aliases: [domainName],
}
),
}
);
Amazon Web Services