AWS Cognito + AWS Lambda + Serverless Framework + React with Redux

Edcarl Adraincem
Expedia Group Technology
11 min readSep 6, 2018

We have covered quite a bit so far on how the backend of the application is structured. Let’s finally round it off with how we have created the UI using React with Redux to play nicely with the AWS Managed Services we have set up in our environments.

If you missed out on following it, check out our other blog post in this series:

In our previous posts, we mentioned the React app is served from a private S3 bucket using an AWS Lambda function. It is being stored in a private S3 bucket to be compliant with our internal security policies. We use the Serverless Framework to develop and deploy our AWS Lambda function and use the events to trigger the React app to render server side.

Things we will cover in this post:

  • Setting up the Serverless Framework and preparing for server side rendering.
  • Generating signed URLs for the JavaScript and CSS files in the private S3 bucket and add them to the HTML string.
  • Sending configuration settings for the AWS API Gateway URLs and AWS Cognito Identity Pool ID as the initial state of the app, which is being stored using Redux.
  • Using settings to configure aws-amplify within our React app.

But First Let’s Create-React-App!

Without much stress, create-react-app makes it simple to create a project and just focus on the code.

Why React for the UI?

The application we were creating had to be very dynamic. Each country and sometimes individual new hires have unique requirements that needed gathering. The main benefit of React is the creation of independent components and, of course, the Virtual DOM. Each component had to be responsive according to the data presented from the people management software. The data passed to the UI is managed and maintained using Redux. We are able to predict the data flow and allow various components access to the data needed.

One thing we noticed is every time we would npm run build the app for production, the build was minified and the filenames included hashes (ie. main.a1234567.js and main.a1234567.js.map). The hashes allow you not to worry about browser caching previous versions. We don’t need to worry about this in our case due to the fact we have to generate a Signed URL to serve the javascript files from the S3 buckets for each session. The Signed URL is generated server side which we will discuss in an upcoming section in this post.

During our automated deployments, we couldn’t keep track of the dynamic hashes in the filename. So we set up a gulp task to rename the files to main.js, main.js.map, main.css, and main.css.map. We also created a task to scan and replace any reference of the hashed filenames with the basic filenames in the code itself.

Now we got that all taken care of… Let’s move onto Server Side Rendering.

Amazon Lambda Function to Server Side Render

Server side rendering handles the initial request when a user first initializes the application. The pros and cons of server side rendering is well documented so we won’t dive into that debate. From our point of view, it is the perfect approach and tool for our particular use case — generating Signed URLs and initializing the Redux store with our AWS Cognito Identity Pool ID and API Gateway URLs.

To begin, here is how our bare directory structure looks with the React app and Serverless Framework:

onboarding-app/
├── /settings # Settings that's maintained per environment
│ ├── defaults.json
│ ├── dev.json
│ └── prod.json
├── /public # React app
├── /src # React app
├── /ssr # Server side rendering
│ ├── html.js
│ └── server.js
├── handler.js
├── package.json
└── serverless.yml

Environment Settings

To make things easy to maintain within our application, we set up a /settings folder to hold most of the settings needed to run the app. This allows us to manage settings per environment and even store sensitive information so it isn’t in rendered code. Using the npm package settings-lib allows us to combine these settings files according to the environment configured from the CLI. For this post, we will only refer to one settings file being imported: /settings/dev.json.

Server What? Serverless!

Huh? What? Why?

Here is a great quick start guide for you to get a new Serverless project running. I also recommend checking out the docs if you are unfamiliar with the Serverless Framework.

Now, let’s set up our Serverless Framework. We set up our AWS Lambda function and the appropriate IAM roles and policies all in serverless.yml.

The IAM policies we are creating below will be used to generate the Signed URLs and get the React app from the private S3 bucket. We also need a policy to invoke the API Gateway we created previously to make a request to fetch data from the people management software.

provider:
name: aws
runtime: nodejs8.10
timeout: 30
stage: ${opt:stage}
region: us-west-2
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:GetObject"
Resource: "arn:aws:s3:::${file(settings/${opt:stage}.json):s3.appBucket}/*"
- Effect: "Allow"
Action:
- "execute-api:Invoke"
Resource: "arn:aws:execute-api:*:*:${file(settings/${opt:stage}.json):onboardingApi.apiId}/*"

What’s with the `${…}` syntax?

Variables allow users to dynamically replace config values in serverless.yml config. They are especially useful when providing secrets for your service to use and when you are working with multiple stages.
Read more here.

Next, we create the AWS Lambda Function which, we have called onboarding

functions:
onboarding:
handler: handler.onboarding
vpc:
securityGroupIds: ${file(settings/${opt:stage}.json):vpc.securityGroupIds}
subnetIds: ${file(settings/${opt:stage}.json):vpc.subnetIds}
events:
- http:
path: /{params}
method: get
cors: true
request:
parameters:
paths:
token: true

With the onboarding Lambda function created, we create the actual function that will be executed in handler.js.

'use strict';const app = require('./ssr/server').default;
const awsServerlessExpress = require('aws-serverless-express');
module.exports.onboarding = (event, context) => {
const server = awsServerlessExpress.createServer(app);
awsServerlessExpress.proxy(server, event, context);
};

Notice that we use aws-serverless-express. This is because the purpose of the onboarding lambda function is to spin up an Express server to execute the Express middleware we set up in /ssr/server.js. This is how we initiate server side rendering and preload the initial state of the app. We will dive more into this shortly.

🤬 CORS

Oh the struggles… Can you tell this was a pain point for us? Since the API Gateway of our lambda function is receiving request from another domain, we need to enable cross-origin resource sharing. Luckily, this can easily be added under resources in the serverless.yml. The great thing is that anything you can define in CloudFormation is supported by the Serverless Framework. The below snippet allows the CORS response headers for the DEFAULT_4XX and DEFAULT_5XX Gateway Responses. Without the response headers, a 403 status code would present itself on the API Gateway URL being invoked.

resources:
Resources:
GatewayResponseDefault4XX:
Type: 'AWS::ApiGateway::GatewayResponse'
Properties:
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
gatewayresponse.header.Access-Control-Allow-Methods: "'GET,PUT,OPTIONS'"
ResponseType: DEFAULT_4XX
RestApiId:
Ref: 'ApiGatewayRestApi'
GatewayResponseDefault5XX:
Type: 'AWS::ApiGateway::GatewayResponse'
Properties:
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
gatewayresponse.header.Access-Control-Allow-Methods: "'GET,PUT,OPTIONS'"
ResponseType: DEFAULT_5XX
RestApiId:
Ref: 'ApiGatewayRestApi'

Server Side Rendering, Redux, and Keeping Out The AWS Cognito Identity Pool ID From Our React Code

With the Serverless Framework all setup, we needed to create the Express middleware the onboarding lambda function will spin up. Remember this is where we need to generate the Signed URLs and inject them into the server side rendered HTML. We also preload some settings in the Redux store. Redux’s only job on the server side is to provide the initial state of our app.

We add our own flavor to how we make it work with all the AWS Managed Services we need. You can find the baseline setup for server rendering along with Redux here. This took us a lot of trial and error (maybe some sweat 😓 and tears 😢 also) as documentation on the topic was sparse.

Signed URLs

Getting the signed URLs for our files was fairly easy. In our settings file, we just provided the directory path for the files within the private S3 bucket. Since an IAM role was created to allow getSignedUrl, we created the generateBatchSignedUrls function in our server side code to generate Signed URLs for each file.

// ./ssr/server.jsimport express from 'express';
import React from 'react';
import AWS from 'aws-sdk';
import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import devSettings from './settings/dev.json';
import Html from './html';
import configureStore from '../src/store/store'; // Redux store
import routes from '../src/components/routes'; // Centralized route config
AWS.config.update({ region: 'us-west-2' });
const s3Client = new AWS.S3();
const app = express();// Function to generate the Signed URLs for each file type in private S3 bucket
const generateBatchSignedUrls = (settings) => {
const signedUrlStore = {};
const { files } = settings.s3;
Object.keys(files).forEach((key) => {
const params = {
Bucket: settings.s3.appBucket,
Key: files[key],
};
signedUrlStore[key] = s3Client.getSignedUrl('getObject', params);
});
return signedUrlStore;
};
// Fired every time server side is requested.
app.use(async (req, res) => {
const store = configureStore();
const { url } = req;
let favicon;
let js;
let manifest;
// Generating signed URLs and destructuring.
({ favicon, js, manifest } = generateBatchSignedUrls(devSettings));
const context = {}; // Render React component to string to be injected into the HTML.
const content = renderToString(
<Provider store={store}>
<StaticRouter context={context} location={url}>
{ renderRoutes(routes) }
</StaticRouter>
</Provider>
);
const preloadedState = {}; // Send rendered page to client with Signed URLs, initial state, and React app content.
res.send(Html({
content,
preloadedState,
manifest,
favicon,
js,
}));
});
export default app;

This is our HTML string sent as the response to the server side render code. It is a direct copy of index.html from the /public folder of our React app with the Signed URLs and preloaded Redux state injected.

const Html = ({ content, preloadedState, manifest, favicon, js }) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href=${manifest}>
<link rel="shortcut icon" href=${favicon}>
<title>Employee Onboarding</title>
</head>
<body><noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">${content}</div>
<script>
window.__PRELOADED_STATE__ = ${preloadedState}
</script>
<script type="text/javascript" src=${js}></script>
</body>
</html>
`;
export default Html;

Amazon Cognito for Unauthenticated Requests

We are use Amazon Cognito to provide authorization and authentication for our employee onboarding application. There are two parts to Amazon Cognito — user pools and identity pools. We will mainly focus on identity pools as we are not having users create credentials for login. This streamlines access and the user experience as they fill out their onboarding form. Also at the time of writing, the application is only meant to be used before a new hire’s start date.

The challenge was to provide the authentication configuration to aws-amplify in the React app without having the Cognito Identity Pool ID show up in the rendered code in the browser. The Identity Pool ID is used to make unauthenticated requests to the API Gateway URLs. This was a concern for us as anyone with enough willpower could possibly coax their way in to gain access to the API.

Our initial thought was to create a secondary app to handle invalid params and didn’t contain any authentication configuration in the rendered code. Not the best idea, but it was a last resort plan.

After some back and forth, the solution was just so obvious that we didn’t think about it. We previously implemented in the onboarding Lambda function a check to render the app if params are valid or to throw an error if params are invalid. Then, we found we could make use of the event object from the Lambda function in the server side code using awsServerlessExpressMiddleware.

We modified the onboarding Lambda function to check params and depending on the success or error of the check, add session or error objects to the event object with the React app rendering in either case. The session object contains the configuration settings for aws-amplify which are in /settings/dev.json from earlier.

// /handler.js// ...
module.exports.onboarding = (event, context) => {
const { params } = event.pathParameters;
const newEvent = {};
let settings;
AWS.config.update({ region: 'us-west-2' }); return checkParamsFunction(
// ...
).then(() => {
Object.assign(newEvent, event, {
session: {
amplify: settings.amplify,
},
});
const server = awsServerlessExpress.createServer(app);
awsServerlessExpress.proxy(server, newEvent, context);
}).catch((err) => {
Object.assign(newEvent, event, {
error: {
statusCode: err.statusCode,
message: err.body,
},
});
const server = awsServerlessExpress.createServer(app);
awsServerlessExpress.proxy(server, newEvent, context);
});
};

Then in server.js, we use awsServerlessExpressMiddleware to get the event object and stringify it to be injected as the preloaded state. Now the session or error objects will be set as the initial state of the React app.

// ./ssr/server.jsimport awsServerlessExpressMiddleware from 'aws-serverless-express/middleware';
import express from 'express';
import React from 'react';
import AWS from 'aws-sdk';
import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import devSettings from './settings/dev.json';
import Html from './html';
import configureStore from '../src/store/store';
import routes from '../src/components/routes';
AWS.config.update({ region: 'us-west-2' });
const s3Client = new AWS.S3();
const app = express();const generateBatchSignedUrls = (settings) => {
const signedUrlStore = {};
const { files } = settings.s3;
Object.keys(files).forEach((key) => {
const params = {
Bucket: settings.s3.appBucket,
Key: files[key],
};
signedUrlStore[key] = s3Client.getSignedUrl('getObject', params);
});
return signedUrlStore;
};
// Middleware to get the event object Lambda receives from API Gateway.
app.use(awsServerlessExpressMiddleware.eventContext());
app.use(async (req, res) => {
const store = configureStore();
const { url } = req;
let favicon;
let js;
let manifest;
({ favicon, js, manifest } = generateBatchSignedUrls(devSettings)); const context = {}; const content = renderToString(
<Provider store={store}>
<StaticRouter context={context} location={url}>
{ renderRoutes(routes) }
</StaticRouter>
</Provider>
);
// Stringify the event object the onboarding Lambda function gets from API Gateway. This will also be injected into the HTML.
const preloadedState = JSON.stringify(req.apiGateway.event).replace(/</g, '\\u003c');
res.send(Html({
content,
preloadedState,
manifest,
favicon,
js,
}));
});
export default app;

How is the preloaded state set as the initial state?

This is explained in the Redux tutorial here.

In our React app, we use the initial state to configure the Auth and API interfaces of aws-amplify. Once the app is mounted, it fetches the remaining data from the Amazon API Gateway URLs.

Why aren’t you preloading all session and employee data during server rendering?

We used to, but things got complicated. Since we were dependent on the communication with the people management software to fetch data, the API calls took some time because each country and employee had their unique requirements. API Gateway has a limit of 29 seconds before a request times out, and requests were barely exceeding that. We are constantly working on ways to optimize the API requests so we can fully have the app render server side.

Conclusion

We covered how we used server side rendering to host an app from a private Amazon S3 bucket and keep security keys out of rendered code. This was even more of a challenge because it is not very well documented online. Hopefully you find this useful for your project.

Feel free to ask questions and leave comments. Thanks!

--

--