Docusaurus Authentication using AWS Cognito

A guide on how to add authentication to your Docusaurus website utilizing AWS Cognito

Featured on Hashnode
Docusaurus Authentication using AWS Cognito

Background

Docusaurus is an excellent documentation generator tool that doesn’t support authentication out of the box. If you want to read about the roadmap of this issue and get to know more about what we want to achieve, please refer to my main article here.

Goal

The main goal of this article is to provide a step-by-step guide on how to add a simple authentication layer to Docusaurus using AWS Cognito.

Please find the more advanced authentication flows using Docusaurus and AWS Cognito here.

Getting Started

Before starting the tutorial, I assume you have a basic understanding of React and how Docusaurus works.

Prerequisite

  • An AWS account

  • A running Docusaurus website using the steps provided in the main website

Installing packages

After initializing your Docusaurus project, it is time to install the necessary packages:

npm i @aws-amplify/ui-react@^4.6.0 add@^2.0.6 aws-amplify@^5.1.4 aws-crt@^1.15.15 docusaurus-plugin-dotenv@^1.0.1

The command above will install a few packages respectively:

  • @aws-amplify/ui-react: The Amplify UI library used in the login page

  • add: A helper package is needed for most of the next packages

  • AWS-amplify: The main AWS Amplify library for JavaScript

  • aws-crt: NodeJS/Browser bindings for the AWS Common Runtime

  • docusaurus-plugin-dotenv: A Docusaurus plugin that allows you to access your environment variables

Update docusaurus.config.js

Go to your docusaurus.config.js file and update the plugins list:

// docusaurus.config.js

plugins: [
    [
      "docusaurus-plugin-dotenv",
      {
        path: "./.env.local",
        systemvars: true,
      },
    ],
  ],

❗️Be careful about the below points regarding the above configuration:

  • path will look for the .env.local file which is needed for test purposes on your local machine.

  • systemvars should be set to true if you would rather load all system variables as well (useful for CI purposes). If it is not the case for you, please set it as false.

AWS Cognito Setup

In this section, we will see how to create an AWS Cognito User Pool to satisfy the requirements.

  1. Go to the Amazon Cognito console. If prompted, enter your AWS credentials.

  2. Choose User Pools.

  3. Choose Create a user pool to start the user pool creation wizard.

  4. In Step 1 (Configure sign-in experience), in the section Cognito user pool sign-in options, choose Email and press Next.

  5. In Step 2 (Configure security requirements), in the section Multi-factor authentication, choose No MFA (you can consider other options based on your flow) and leave the rest as they are, and press Next.

  6. In Step 3 (Configure sign-up experience), you may check Enable Self-registration if your flow allows the users to self-register. Leave the rest as they are, and press Next.

  7. In Step 4 (Configure message delivery), choose to Send email with Cognito to let Cognito takes care of the emails. Though, you may choose to Send email with Amazon SES based on the requirements and press Next.

  8. In Step 5 (Integrate your app), in the section User pool name, write a name for the user pool.

  9. In Step 5 (Integrate your app), in the section Hosted authentication pages, opt for Use the Cognito Hosted UI.

  10. In Step 5 (Integrate your app), fill up the Cognito domain based on the requirements. The only point is to note down the Cognito domain since we will need it later to connect the Docusaurus website to AWS Cognito.

  11. In Step 5 (Integrate your app), in the section Initial app client, fill up the name in the App client name field.

  12. In Step 5 (Integrate your app), in the section Client secret, choose Don't generate a client secret. Then fill up the Allowed callback URLs based on the requirements.

  13. In Step 5 (Integrate your app), in the section Advanced app client settings, add the Allowed sign-out URLs - optional and press Next.

  14. In Step 5 (Review and create), review your choices and modify any selections you wish to. When you are satisfied with your user pool configuration, select Create user pool to proceed.

❓In the above steps:

  • localhost:3000 is my local development setup.

  • https://docusaurus-auth-cognito.vercel.app is the URL that I published my docusaurus example on. YOU NEED TO WRITE YOURS.

Docusaurus Setup

This section contains all the changes and new files needed to be added to the Docusaurus website. I suggest going through the previous steps to set up your AWS Cognito before starting this section.

Final Directory Hierarchy

Here is the final directory tree which mostly shows the changes added/modified to the Docusaurus project. Use it as a reference.

├── docusaurus.config.js
└── src
   ├── components
   |  ├── Auth
   |  |  └── index.js
   |  └── Login
   |     ├── index.js
   |     └── styles.css
   ├── config
   |  ├── amplify-config.js
   |  └── aws-exports.js
   ├── pages
   |  └── login
   |     └── index.js
   ├── theme
   |  ├── Navbar
   |  |  ├── Content
   |  |  |  ├── index.js
   |  |  |  └── styles.module.css
   |  |  └── MobileSidebar
   |  |     └── PrimaryMenu
   |  |        └── index.js
   |  └── Root.js
   └── utils
      ├── constants.js
      └── utils.js

Docusaurus Swizzling

Docusaurus has an advanced feature called Swizzling which allows the developer to go deeper into the Docusaurus lifecycle and provides more flexibility to the developer.

To make our website be able to utilize sign-up/sign-in flow, we need to swizzle three components:

  1. ROOT: The <Root> component is rendered at the very top of the React tree, above the theme <Layout>, and never unmounts. It is the perfect place to add stateful logic that should not be re-initialized across navigation (user authentication status, shopping cart state...).

  2. Navbar: To show the Login/Logout button in the Navbar on top of the website.

    • Desktop: To show the button in the Desktop view.

    • Mobile: To show the button in the Mobile view.

To swizzle the above components, you may use the instruction from Docusaurus or follow my guide to add the required files.

Check here for the final directory hierarchy.

Adding Required Files

This section will provide the changes or additional files required for this project.

🔔 On the first line of each code block, you can find the relative path of the file.

  • Environment variables: Contains all the variables used in the application. I've provided some explanations for each variable.

      # .env.local
    
      # i.e: localhost, dev, prod
      ENV="localhost"
    
      # AWS Cognito Region
      REGION="ap-southeast-1"
    
      # AWS Cognito User Pool ID
      USER_POOL_ID="ap-southeast-1_f5XdaserR"
    
      # AWS Cognito User Pool App Client ID
      USER_POOL_WEB_CLIENT_ID="7858pxvalkeqenbv3sac"
    
      # AWS Cognito Domain
      OAUTH_DOMAIN="docusaurus-auth-cog.auth.ap-southeast-1.amazoncognito.com"
    
      # Amplify redirect url after a successful sign-in
      OAUTH_REDIRECT_SIGN_IN="http://localhost:3000/login,https://docusaurus-auth-cognito.vercel.app/login"
    
      # Amplify redirect url after a successful sign-out
      OAUTH_REDIRECT_SIGN_OUT="http://localhost:3000,https://docusaurus-auth-cognito.vercel.app"
    
      # Amplify setup, no need to change it!
      OAUTH_REDIRECT_SIGN_RESPONSE_TYPE="code"
    
  • aws-exports.js: Contains the definitions needed to set up AWS Amplify.

      // src/config/aws-exports.js
    
      const awsConfig = {
          region: process.env.REGION,
          userPoolId: process.env.USER_POOL_ID,
          userPoolWebClientId: process.env.USER_POOL_WEB_CLIENT_ID,
          oauth: {
              domain: process.env.OAUTH_DOMAIN,
              redirectSignIn: process.env.OAUTH_REDIRECT_SIGN_IN,
              redirectSignOut: process.env.OAUTH_REDIRECT_SIGN_OUT,
              responseType: process.env.OAUTH_REDIRECT_SIGN_RESPONSE_TYPE
          }
      };
    
      export default awsConfig;
    
  • amplify-config.js: Sets up the AWS Amplify based on the configurations passed to it and it will decide to choose some settings based on the environment whether it is localhost or any other URL.

      // src/config/amplify-config.js
    
      import awsConfig from "./aws-exports";
    
      export function configure() {
          // Assuming you have two redirect URIs, and the first is for localhost and second is for production
          const [
              localRedirectSignIn,
              productionRedirectSignIn,
          ] = awsConfig.oauth.redirectSignIn.split(",");
    
          const [
              localRedirectSignOut,
              productionRedirectSignOut,
          ] = awsConfig.oauth.redirectSignOut.split(",");
    
          const updatedAwsConfig = {
              ...awsConfig,
              oauth: {
                  ...awsConfig.oauth,
                  redirectSignIn: process.env.ENV === 'localhost' ? localRedirectSignIn : productionRedirectSignIn,
                  redirectSignOut: process.env.ENV === 'localhost' ? localRedirectSignOut : productionRedirectSignOut,
              }
          };
    
          return updatedAwsConfig;
      }
    
  • Root.js: Here we wrap the main <Root> component using <Authenticator.Provider>.

      // src/theme/Root.js
    
      import React from 'react';
    
      import { Amplify } from 'aws-amplify';
      import { Authenticator } from '@aws-amplify/ui-react';
    
      import { configure } from "../config/amplify-config";
      import { AuthCheck } from "../components/Auth";
    
      const awsConfig = configure();
      Amplify.configure(awsConfig);
    
      export default function Root({ children }) {
        return (
          <Authenticator.Provider>
            <AuthCheck children={children} />
          </Authenticator.Provider>
        );
      }
    
  • Auth: In this file, we check if the route requested is private or not.

    1. If the route is protected and the user is authenticated, it will redirect to the requested page.

    2. If the route is protected and the user is not authenticated, it will redirect to the login page.

    3. If the route is login or logout, it will redirect to the base URL.

    4. Otherwise, means the path is not protected and will redirect to the requested page.

       // src/components/Auth/index.js
      
       import React from "react";
      
       import { useAuthenticator } from "@aws-amplify/ui-react";
       import { Redirect, useLocation } from "@docusaurus/router";
      
       import { AUTHENTICATED, BASE, LOGIN_PATH, LOGOUT_PATH, PROTECTED_PATHS } from "../../utils/constants";
       import { Login } from "../Login";
      
       export function AuthCheck({ children }) {
      
           const location = useLocation();
           let from = location.pathname;
      
           const { route, signOut } = useAuthenticator((context) => [context.route, context.signOut]);
      
           // If it is not authenticated and tries to access the protected paths. Also, a
           // custom error appears if anything happens.
           if (route === AUTHENTICATED) {
               if (from === LOGOUT_PATH) {
                   signOut();
                   return <Redirect to={BASE} from={LOGOUT_PATH} />;
               } else if (from === LOGIN_PATH) return <Redirect to={BASE} from={from} />;
      
               return children;
           } else {
               if (from === LOGOUT_PATH) return <Redirect to={BASE} from={from} />;
               else if (PROTECTED_PATHS.filter(x => from.includes(x)).length) return <Login />;
               else if (from === LOGIN_PATH) return <Login />;
               return children;
           }
       }
      
  • Login: This page will be shown when the user needs to log in or be redirected to /login page.

    Additionally, I wrote a CSS file tied to this page which mostly fixes the bugs for the AWS Amplify library used above (hopefully it will be fixed in future releases).

      // src/pages/login/index.js
    
      import React from "react";
    
      import { useHistory, useLocation } from "@docusaurus/router";
    
      import { Login } from "../../components/Login";
    
      import { BASE, LOGIN_PATH } from "../../utils/constants";
    
      export default function loginPage() {
        const location = useLocation();
        const history = useHistory();
    
        let from = location.pathname;
        let to =
          history.location.pathname === LOGIN_PATH
            ? BASE
            : history.location.pathname;
    
        return <Login from={from} to={to} />;
      }
    
      // src/components/Login/index.js
    
      import React from "react";
    
      import { Authenticator, View } from "@aws-amplify/ui-react";
      import "@aws-amplify/ui-react/styles.css";
    
      import { Redirect } from "@docusaurus/router";
    
      import "./styles.css";
    
      export function Login({ from, to }) {
        return (
          <View className="auth-wrapper">
            <Authenticator>{<Redirect to={to} from={from} />}</Authenticator>
          </View>
        );
      }
    
      /* src/components/Login/styles.css */
    
      .auth-wrapper {
        margin: auto 10px;
      }
    
      /* it is necessary because of the bug in the main css library of the AWS Amplify */
      .amplify-field-group__outer-end .amplify-field-group__control,
      .amplify-field-group__outer-start .amplify-field-group__control {
        height: 100%;
        height: -webkit-fill-available;
      }
    
      /* it is necessary because of the bug in the main css library of the AWS Amplify */
      .amplify-button--fullwidth {
        width: inherit;
      }
    
  • Navbar Desktop: Nothing changes on this component except we want to add Login/Logout to the Navbar.

    The reason we don't add the button using docusaurus.config.js is that the text should switch between Login and Logout based on the fact whether the user is authenticated or not.

    So, we just override useNavbarItems() function and use the modified function placed src/utils/utils.js.
    Additionally, there is a CSS file tied to this page which is the original one captured from the swizzling component.

    The same change happens in Navbar Mobile as well.

      // src/theme/Navbar/Content/index.js
    
      import React from 'react';
      import { useThemeConfig, ErrorCauseBoundary } from '@docusaurus/theme-common';
      import {
        splitNavbarItems,
        useNavbarMobileSidebar,
      } from '@docusaurus/theme-common/internal';
      import NavbarItem from '@theme/NavbarItem';
      import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle';
      import SearchBar from '@theme/SearchBar';
      import NavbarMobileSidebarToggle from '@theme/Navbar/MobileSidebar/Toggle';
      import NavbarLogo from '@theme/Navbar/Logo';
      import NavbarSearch from '@theme/Navbar/Search';
      import styles from './styles.module.css';
      import { useNavbarItems } from '../../../utils/utils';
      // function useNavbarItems() {
      //   // TODO temporary casting until ThemeConfig type is improved
      // return useThemeConfig().navbar.items;
      // }
      function NavbarItems({ items }) {
        return (
          <>
            {items.map((item, i) => (
              <ErrorCauseBoundary
                key={i}
                onError={(error) =>
                  new Error(
                    `A theme navbar item failed to render.
      Please double-check the following navbar item (themeConfig.navbar.items) of your Docusaurus config:
      ${JSON.stringify(item, null, 2)}`,
                    { cause: error },
                  )
                }>
                <NavbarItem {...item} />
              </ErrorCauseBoundary>
            ))}
          </>
        );
      }
      function NavbarContentLayout({ left, right }) {
        return (
          <div className="navbar__inner">
            <div className="navbar__items">{left}</div>
            <div className="navbar__items navbar__items--right">{right}</div>
          </div>
        );
      }
      export default function NavbarContent() {
        const mobileSidebar = useNavbarMobileSidebar();
        const items = useNavbarItems();
        const [leftItems, rightItems] = splitNavbarItems(items);
        const searchBarItem = items.find((item) => item.type === 'search');
        return (
          <NavbarContentLayout
            left={
              // TODO stop hardcoding items?
              <>
                {!mobileSidebar.disabled && <NavbarMobileSidebarToggle />}
                <NavbarLogo />
                <NavbarItems items={leftItems} />
              </>
            }
            right={
              // TODO stop hardcoding items?
              // Ask the user to add the respective navbar items => more flexible
              <>
                <NavbarItems items={rightItems} />
                <NavbarColorModeToggle className={styles.colorModeToggle} />
                {!searchBarItem && (
                  <NavbarSearch>
                    <SearchBar />
                  </NavbarSearch>
                )}
              </>
            }
          />
        );
      }
    
      /* src/theme/Navbar/Content/styles.module.css */
    
      /*
      Hide color mode toggle in small viewports
       */
      @media (max-width: 996px) {
        .colorModeToggle {
          display: none;
        }
      }
    
  • Navbar Mobile: Nothing changes on this component except we want to add Login/Logout to the Navbar.

    The reason we don't add the button using docusaurus.config.js is that the text should switch between Login and Logout based on the fact whether the user is authenticated or not.

    So, we just override useNavbarItems() function and use the modified function placed src/utils/utils.js.

    The same change happens in Navbar Desktop as well.

      // src/theme/Navbar/MobileSidebar/PrimaryMenu/index.js
    
      import React from 'react';
      import { useThemeConfig } from '@docusaurus/theme-common';
      import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal';
      import NavbarItem from '@theme/NavbarItem';
      import { useNavbarItems } from '../../../../utils/utils';
      // function useNavbarItems() {
      //   // TODO temporary casting until ThemeConfig type is improved
      //   return useThemeConfig().navbar.items;
      // }
      // The primary menu displays the navbar items
      export default function NavbarMobilePrimaryMenu() {
        const mobileSidebar = useNavbarMobileSidebar();
        // TODO how can the order be defined for mobile?
        // Should we allow providing a different list of items?
        const items = useNavbarItems();
        return (
          <ul className="menu__list">
            {items.map((item, i) => (
              <NavbarItem
                mobile
                {...item}
                onClick={() => mobileSidebar.toggle()}
                key={i}
              />
            ))}
          </ul>
        );
      }
    
  • constants.js: This file contains the most important constant values giving easier management of the project.

      // src/utils/constants.js
    
      export const LOGIN_PATH = '/login';
      export const LOGOUT_PATH = '/logout';
      export const AUTHENTICATED = 'authenticated';
      export const BASE = '/';
    
      export const LOGOUT_BUTTON = "Logout";
      export const LOGIN_BUTTON = "Login";
    
      // Add the protected paths here
      export const PROTECTED_PATHS = [BASE];
    
  • utils.js: Contains a function to add Login/Logout buttons to the Navbar.

      // src/utils/utils.js
    
      import { useThemeConfig } from '@docusaurus/theme-common';
    
      import { useAuthenticator } from '@aws-amplify/ui-react';
      import { AUTHENTICATED, LOGIN_BUTTON, LOGIN_PATH, LOGOUT_BUTTON, LOGOUT_PATH } from "./constants";
    
      export function useNavbarItems() {
          const { route } = useAuthenticator((context) => [context.route]);
    
          let label, to;
          if (route === AUTHENTICATED) {
              label = LOGOUT_BUTTON;
              to = LOGOUT_PATH;
          } else {
              label = LOGIN_BUTTON;
              to = LOGIN_PATH;
          }
    
          // TODO temporary casting until ThemeConfig type is improved
          // return useThemeConfig().navbar.items;
          let items = useThemeConfig().navbar.items;
          items.push({
              label: label,
              position: "right",
              to: to
          });
    
          // remove irrelevant items
          if (route === AUTHENTICATED) items = items.filter(x => x.label !== LOGIN_BUTTON);
          else items = items.filter(x => x.label !== LOGOUT_BUTTON);
    
          const uniqueItems = [...new Map(items.map(x => [x.label, x])).values()];
    
          return uniqueItems;
      }
    

Source Code

You may find the full source code of this project here.

🔔 The code repository shared above consists of a few branches that all are related to the Docusaurus Authentication using AWS Cognito. Though, you can find the changes for this article in the branch called main.


❗️DO NOT upload any .env to your public repository.

To run the project on your machine, you only need to fill up the .env.local file as described WITH YOUR VALUES.

To use your .env values in production, you need to add them to your hosting.

Below I listed a few hosting options and how you can upload your .env variables as well.

Authentication for Specific Route

Please refer to it here.

Authentication using Social Providers

Please refer to it here (coming soon).

Adding limitation to SignUp/SignIn flow

Please refer to it here (coming soon).

Complete Docusaurus Authentication Series

Please find the complete roadmap here.

In this series, I will provide a step-by-step guide on how to add an authentication layer to your Docusaurus website using well-known auth providers like AWS Cognito, Google Firebase, Auth0, etc.

Also, you can find more advanced auth flows for complicated scenarios as well.

Support

In the end, if you find the articles useful, don’t forget to support me at least with a message :)

Did you find this article valuable?

Support Massoud Maboudi by becoming a sponsor. Any amount is appreciated!