AWS Cognito Web App in Pure JavaScript

Back in the early days of the World Wide Web, implementing user authentication was very straight forward. I remember a time where we only used a simple HTML page with a username and password field that was submitted to a CGI application which would check the password against something stored in a database and then issue a cookie. Both Cookies and HTTPS were born out of Netscape Communications, but it would take a long time before HTTPS was widely adopted. Therefore, in the late 90's and early 2000's the WWW was the Wild West and it is always amazing to me to reflect on just how far we have come.

Today, user authentication is a lot more complex and it is much harder today to properly secure the user authentication process in web applications. Thankfully web frameworks and SDK's have made the process easy, but it still helps to understand the nuts and bolts. This blog post explores the nuts and bolts in the context of what is these days considered a standard flow for authenticating users using a web application: OAUTH2 Authorization Code flow with PKCE.

What is this all about?

In a nutshell:

This is a detailed walk through with an example web application that will authenticate an already registered and verified user in AWS Cognito in a Web Application using the OAUTH2 Authorization Code flow with PKCE, using pure JavaScript and not relying at all on any AWS SDK's

That was still a mount full, so let's break that down in smaller junks with a little more detail.

The example web implementation I refer to is also available on GitHub and all discussions will be referring to this code base, unless otherwise stated.

The idea is to use AWS Cognito to authenticate our web users. That means users that register will have their profiles stored in AWS. This is convenient for several reasons:

This blog post and experimental steps are based on an AWS blog post titled Understanding Amazon Cognito user pool OAuth 2.0 grants.

For this experiment an AWS Cognito user pool was must exist already with at least one user registered and confirmed. I will not delve into the detail of the steps, but I will share some key configuration decisions that are important.

The web application is a very straight forward HTML and JavaScript application without using any modern frameworks or SDK's. My aim was to go "raw" so that I could follow the steps in as much detail as possible. I take this approach whenever I want to really learn the nuts and bolts of something - it's definitely not required, but it does help when things go wrong and then trying to troubleshoot. Understanding the steps involved may at the very least help to pinpoint an issue you may experience when using an SDK with a framework of your choice.

A pure JavaScript implementation in this case means that I did not use any modern framework, but I did end up using some existing JavaScript projects that provided some key function (especially the cryptographic functions) as these are not the easiest thing in the world to implement from scratch and using the existing code is far more effective than trying to copy and paste all the working parts from StackOverflow or something similar. Also note that I am not a particularly proficient JavaScript developer, so the code may not be on any professional level - it simply has enough stuff to get the basic flow working.

Important Security Information: Based on the previous paragraph, it should therefore be noted at this stage that the code examples is not at all intended for a production environment. In fact, there are some known security issues that I will list below. To follow best practices and secure your application properly, please use the AWS Amplify JavaScript SDK with your preferred web application framework.

Known Security Issues in this example code base:

The experiment implements the OAUTH2 Authorization Code flow with PKCE. Please note that PKCE is an important addition and if you have a web application implementing Authorization Code flow, you really should also be implementing it with PKCE.

Required Background Knowledge

This project should be ideal for JavaScript or web developers as a means to learn about AWS Cognito integration on a lower level. With that said, some general experience in the following technologies will help:

AWS Cognito Preparations

In order to actually use the code base yourself, You will need to create an AWS Cognito User Pool and define at least one web application.

Configuring the application is the tricky part, but full instructions are available in the AWS Cognito Documentation.

You will need to configure a "Public Client" and most of the defaults should be just fine.

Add the following callback URL: http://localhost:8080/callback.html

Add the following sign-out URL: http://localhost:8080/loggedout.html

Note: All callback and sign-out URL's must be HTTPS except localhost, which is permitted to ne normal HTTP to allow testing from your local machine.

Include the following scopes:

The aws.cognito.signin.user.admin scope is probably the most important for this test, as it is required to make API calls to the Cognito API for functions such as getting the user's profile (all their attributes) and to sign out a user. All operations for the API are listed here but not all these operations can be called with just the access token and the aws.cognito.signin.user.admin scope. I found a list of supported operations on StackOverflow and duplicate the list here for convenience:

In the sample code, GetUser and GlobalSignOut will be used.

For this specific test, the following attributes are required and needs to be defined in the setup of the user pool:

Since you may be running in a sandbox environment for your messaging, you will need to pre-verify your e-mail address (and/or cellphone number).

Finally, pre-register at least one user you can use for testing. The code examples does not include user sign-up examples.

A final word before we deep dive

I am not a JavaScript expert at all. In fact, my JavaScript knowledge is probably still on beginner level as I only occasionally experiment in it. I am not a front-end guy, although the processes interest me and therefore I sometimes do crazy stuff like this.

Therefore, if you are a more experienced JavaScript developer and you would like to optimize the example code, please feel free to make a pull request with your enhancements - I will be more than happy to consider your contributions to improve the overall project. However, I will ask to refrain from using SDK's as I would still like all code to be pure JavaScript.

Testing the Web Application

Let's discuss the sample application available on GitHub in a little more detail.

If you want to test the code yourself, and assuming you have created a Cognito User Pool and have set-up all the required values in the file webapp.js, you will need to run the following Docker command to start serving the web pages:

docker run --rm -p 8080:80 -v $(pwd):/usr/share/nginx/html nginx:latest

For best results, I recommend opening http://localhost:8080/index.html in a browser that has developer tools available (most modern web browsers), and then open the developer tools and ensure that you disable the cache for this site (you could potentially also use a plugin for disabling caching). You may also which to persist logs in order to follow any network calls between domains.

The landing page

The main page of the test application is located at http://localhost:8080/index.html

screenshot 001

The landing page depends only on one JavaScript source file: webapp.js - this file contains all the common code of the application.

When the HTML page loads, it executes some JavaScript code to check if the user is logged in. Content is rendered based on the outcome of the test.

If the user is NOT logged in, a link to the login page is displayed.

The check for a user's login status involves the following: Check if the following values have been set in the browser's session storage:

The login page

This is another static page, named pure-js.html

When the page loads, it also checks if the user is logged in. If a user is found to be logged in, the page will redirect back to the home page (index.html). Again the common function isLoggedIn() is used for the check.

When the user is confirmed not to be logged in, a Login button is rendered and bound to an onClick event that will call loginFunction().

screenshot 002

Clicking the Login button will start the process, and in particular Step 1 of the authorization code grant as explained on this blog post from AWS - the whole process is done in loginFunction().

The codeChallenge is derived from a random value stored in the codeVerifier variable. Both values are saved in the session storage. All the calculated values are used to construct a URL that the user will be redirected to. This URL is hosted on AWS and if all checks passes, the Cognito Login page will be displayed.

Part of the constructed URL contains the callback URL which Cognito will be redirecting back to - regardless if an error occurs or if a user is successfully authenticated - more details on this process in a moment. However, please note that this callback URL must also be one of those defined in your Cognito application callback URL's. For this application it is http://localhost:8080/callback.html

Also note that the Cognito login page can be customized with a logo and some custom CSS - details available in the AWS Documentation.

The callback page

When Cognito returns a user to the callback page there can be either an error condition or the user successfully logged in in which case we then need to retrieve the various tokens. If an error was returned, the error is rendered and any further processing stops.

However, another important check to do is to ensure the state value is the same value as generated during the process when the loginFunction() was called. Assuming the state value is the same as the current value in the session storage, the rest of the process can now continue.

Obtaining tokens is done by means of a API call in the background. This now corresponds to Step 5 of the authorization code grant as explained on this blog post from AWS. Processing is specific to the callback page and therefore all JavaScript code is on this page.

When the remote call to obtain the tokens are successful, the session information is stored in the browsers session storage.

Important Security Notice: Normally you would need to verify the returned JWT. This process is well documented in the AWS documentation, however it was NOT implemented here. Normally the SDK would take of this, but since I am not using an SDK and since this was not an immediate concern, I did not bother implementing it for this example.

When the tokens are stored, another API call is made and uses the newly obtained access token to retrieve the user's profile. The actual call is getProfile("/index.html"); and the index page indicates the page to redirect to if the retrieval of the profile was successful.

When this call is made, Cognito will validate the access token and retrieve the user's profile based on the user ID embedded in the access token.

All data is stored in the browsers session storage and you can use your browsers development tools to view the values:

screenshot 004

You can use an online tool like jwt.io to view the access token values decoded. In my example, I saw the following in the payload data section:

{
  "sub": "121b54a7-43ca-45e9-9cf1-1c645a25a5fb",
  "iss": "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_XXXXXXXXX",
  "version": 2,
  "client_id": "3cvajr5ikekrtrj76vgec0rfdh",
  "origin_jti": "7bfe4468-60d1-4335-9667-ee97013f6126",
  "event_id": "065e254e-33c7-4916-92f7-167117e8810f",
  "token_use": "access",
  "scope": "aws.cognito.signin.user.admin profile email",
  "auth_time": 1654498878,
  "exp": 1654502478,
  "iat": 1654498878,
  "jti": "70db6d96-06a9-4446-8ca6-47d185dab460",
  "username": "121b54a7-43ca-45e9-9cf1-1c645a25a5fb"
}

Cognito uses the sub value to look up the user profile data.

screenshot 003

The call to retrieve the user profile data returns the following data:

{
  "UserAttributes": [
    {
      "Name": "sub",
      "Value": "121b54a7-43ca-45e9-9cf1-1c645a25a5fb"
    },
    {
      "Name": "email_verified",
      "Value": "true"
    },
    {
      "Name": "given_name",
      "Value": "xxxxxxxxxx"
    },
    {
      "Name": "family_name",
      "Value": "xxxxxxxxxx"
    },
    {
      "Name": "email",
      "Value": "xxxxxxxxxx@xxxxxxxxxx.com"
    }
  ],
  "Username": "121b54a7-43ca-45e9-9cf1-1c645a25a5fb"
}

You will now be redirected to the index page, where the call to isLoggedIn() will return true and the page will be rendered with a logout button and your e-mail address as retrieved from the profile.

screenshot 005

Logging out

Clicking the logout button, calls the goLogout() function. This function redirects the user to loggedout.html which in turn will check if the user is still logged in. If not, the user is redirected straight back to the home page, but otherwise a call is first made to the GlobalSignOut Cognito API using the current access token. Cognito will verify the token and delete it on the AWS side, effectively logging the user out of this particular session (any other session against the same user in different browsers should stay unaffected).

All data in the browsers session storage is also removed, so even if the call to Cognito fails for some reason, the tokens will no longer be in the browser session storage and any other potential calls to API's requiring access tokens should now fail.

Finally, the user is redirected back to the home page and you should see the initial landing page once again.

Wrapping up and final thoughts

I think this was an interesting exercise to once again see how the process of logging in to AWS Cognito using an industry standard flow like OAUTH2 Authorization Code flow actually works. Not all security features were implemented (token validation as an obvious example), but the entire flow is implemented and the access token is successfully used in order to retrieve the user's profile.

The key takeaways for me was the following:

AWS Cognito is not without it's challenges though. The service is supposed to be highly available, but keep in mind that it is not a global service - it's a regional service and user profiles exist regionally. This adds some complexity and you may need to keep the following in mind:

I think this is a wrap. I hope you enjoyed this post - especially if you have read this far!

Other references and recognitions

References:

Other projects and recognitions:

Tags

aws, cognito, authentication, authorization, security