A not so simple client webpage
A few months ago, I was tasked with deploying a static website to production. The client wanted a pretty cheap website to promote their business. Dealing with someone who wasn’t tech-savvy, I expected tons of changing requirements but saw an opportunity to motivate myself to learn the landscape of AWS in 2024. Let’s start with some made-up requirements I gave myself:
- The website needs to be deployed using AWS
- Git would be used for version control.
- It would be a single-page app (SPA).
- A RDMS database would not be used to save costs.
I’m primarily a back-end engineer who has jumped around from back-end, data, and site reliability engineering. While I have done full-stack development, I am not qualified in today’s modern landscape with new tooling and best practices. I scraped together a basic react page that used an express server to serve the page. This was all fine locally. The content was read from a JSON file and generated that way, so relying on an RDMS was unnecessary. I assumed I’d probably get away with teaching them to upload an image somewhere and edit the JSON file directly on GitHub.
The site works locally. Now what? Deployment.⌗
I initially zipped the project up and deployed it to Beanstalk, which needed a load balancer to support HTTPS on the domain bought from Godaddy. Route53 was set up for the domain and the correct url and I used ACM for the certificate. The load balancer and ec-2 instance proved rather costly using a classic LB and a micro-instance. However, this was the easiest way to deploy without changing much from the local project.
New requirement: The solution has to be cheaper than the Beanstalk solution. After all, I’m sure paying a 3rd party service to do this would be cheaper. However, the whole point was to learn while delivering a product.
I decided to look into hosting static websites with S3. I followed this tutorial:
I was able to get the index.html file up, and it worked. Uploaded some images, and that worked as well! Here are some issues:
- None of the links worked.
- I bypassed this by creating folders for each route and putting the index.html in each folder. The code determined what to render based on the
window.url
. This is a bit hacky, but could be managed with a GitHub actions that generates the static files, so I wasn’t too worried. - I tried redirection rules, but it also changed
window.url
, so my code logic did not work the way I expected it to.
- I bypassed this by creating folders for each route and putting the index.html in each folder. The code determined what to render based on the
- HTTPS is not supported. This is a deal breaker!
CloudFront to the rescue!⌗
After some googling, I learned I could use CloudFront and attach a certificate to it. CloudFront charges based on invalidations which wouldn’t exceed the free tier for this project. Here are some useful tutorials:
- Deploy a React-based single-page application to Amazon S3 and CloudFront
- Create Multiple Static Sites In A Single AWS S3 Bucket Served By AWS CloudFront
- How to deploy a Hugo site to S3 in 2024
Main Resource
- This covered everything I needed! It worked so well that I created this blog site using this tutorial.
I wanted proper URL routing. It’d be nice to have redirection rules for different types of paths where I did not see any .html extensions. This was solvable a CloudFront function:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Logging
console.log(request)
// Check whether the URI is index.html
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
How do we update the site content?⌗
Remember that assumption I made about that I could teach the client how edit JSON and link to manually upload images. It dawned on me that I’d probably want this project to end and not have to deal with on-going tech support because I could easily see this going on forever. I decided I’d hack together a CMS/admin page where they can do CRUD functions on that JSON file and upload an image. I initially started with a lambda function and quickly realized this wasn’t going to work well and I could use lambda Edge for CloudFront!
First Lesson Learned: You can’t use lambda layers on LambdaEdge. My layer package size was too big. I had the AWS SDK as part of my node_modules which takes up a ton of space. There is a limit to 1MB total package size for LambdaEdge. I also learned later that I didn’t need to include the AWSK SDK in my zip since imports work as long as you are using the correct import format for the version of the sdk/node you’re using:
// This won't work with the newer SDK
//const AWS = require('aws-sdk');
import { createRequire } from 'module';
const require = createRequire(import.meta.url); // backwards compatibility for old code
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
Additional Requirements⌗
- Users need to log in somehow. I could manually create accounts from AWS.
- Logged in users can access an admin page where they can view all posts, add a new posts, delete a post and update a post. They can also upload a single image for a post. That’s all I planned to support. Anything else would require an actual spec and I would treat this would go from a learn project to a real project.
I don’t feel like building auth. How can I hack this?⌗
Maybe I could check if passed in creds to matched an environment variables on a lambda function? Way to hacky for me. I later learned this wouldn’t work anyways, Lambda@Edge doesn’t support environment variables. The credentials would have to be hard-coded.
Let’s learn Amazon Cognito!⌗
It can help with the registration and login process, create groups and users and I can save some time. Back to google:
- Authentication at the edge with Lambda@Edge and Cognito
- Authenticated Access to S3 with Amazon Cognito [Life saver].
I used the UI and setup a cognito user. Then I set up username and password requirement only. I was able to use the default cognito auth flow to register the first user. I created an admin group from the AWS console. The intention is anyone can register for the admin panel but only users manually added to the admin group would actually have access to the page. I could later disable registrations.
I need regular client website to work for everyone and if a hit to the /admin path occurs, redirect to the cognito login page and then if the user is logged in redirect to the /admin with a verified user. Using the links above I tried this:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
const jwt = require('jsonwebtoken');
const jwkToPem = require('jwk-to-pem');
const cognito = new CognitoIdentityProviderClient({ region: 'us-east-1' });
const userPoolId = 'us-east-1_<ID>';
const base_url = 'https://baseurlcom';
let cachedKeys;
const getPublicKeys = async () => {
if (!cachedKeys) {
const { Keys } = await cognito.listUserPoolClients({
UserPoolId: userPoolId
}).promise();
cachedKeys = Keys.reduce((agg, current) => {
const jwk = { kty: current.kty, n: current.n, e: current.e };
const key = jwkToPem(jwk);
agg[current.kid] = { instance: current, key };
return agg;
}, {});
}
return cachedKeys;
};
const isTokenValid = async (token) => {
try {
const publicKeys = await getPublicKeys();
const tokenSections = (token || '').split('.');
const headerJSON = Buffer.from(tokenSections[0], 'base64').toString('utf8');
const { kid } = JSON.parse(headerJSON);
const key = publicKeys[kid];
if (key === undefined) {
throw new Error('Claim made for unknown kid');
}
const claim = await jwt.verify(token, key.key, { algorithms: ['RS256'] });
if (claim['cognito:groups'].includes('admins') && claim.token_use === 'id') {
return true;
}
return false;
} catch (error) {
console.error(error);
return false;
}
};
//exports.handler = async (event) => {
export const handler = async (event, context, callback) => {
let request = event.Records[0].cf.request;
const headers = request.headers;
let uri = request.uri;
// Admin Flow
if (uri.includes('admin')) {
// see if user is logged in
let isValid = false;
if (headers.authorization && headers.authorization[0].value) {
const token = headers.authorization[0].value.split(' ')[1];
isValid = await isTokenValid(token);
}
//let isValid = true;
if (isValid) {
return request;
}
else {
// redirect to login page and redirect back here after success
const redirectResponse = {
status: '302',
statusDescription: 'Found',
headers: {
'location': [{
key: 'Location',
value: '<COGNITO_LOGIN_URL>',
}]
},
};
callback(null, redirectResponse);
}
}
// Regular flow
if (uri.endsWith('/')) {
request.uri += 'index.html';
}
else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
};
This somewhat worked. It would never actually hit the /admin
page after logging in. My hunch is this was a cross-domain cookie issue and setting the custom domain on cognito for auth.domain.com
would resolve it. At this point, after showing my progress, the client was intrigued and wanted to actually do a full fledged site. I didn’t have the time, capacity or the motivation to take on a long term project that would require social media management and a real CMS. They found a great deal for a company that handles it and I got to learn a ton of stuff just from this project so happy ending for everyone! Since the project was cancelled a while ago, I wrote this from memory. If there’s any specific details that are not covered here, post in the comments, and I’ll update this post accordingly.
Lessons learned⌗
- I’m sure there’s better ways to do this and I was in too deep to explore amplify.
- Lambda functions need to use Lambda layers to surpass the 4 MB limit especially when using external node libraries.
- Edge has a 1mb limit for the entire package.
- S3 websites do not support HTTPS, but CloudFront does.
- Cognito is cost-effective and easy for user auth.
- Think about routing rules and plan accordingly. If your website is going to render based on url, your rules need to be set correctly.
- Github Actions make this deployment seamless.
- Github pages probably makes this whole process simplier. I should learn how https and custom domains work on that.