How to manifest streamlined authentication: AWS Cognito in a React app

Cover for How to manifest streamlined authentication: AWS Cognito in a React app

Translations

If you’re interested in translating or adapting this post, please contact us first.

Tired of the complexities and time-consuming tasks involved in implementing authentication in your React TypeScript application? What if there was a way to simplify the process?

Authentication. A patience-testing task regardless of developer seniority: securely managing user credentials, integrating with various identity providers, handling different authentication protocols.

And if we combine authentication, AWS, and React, things can become even more obscure. And yet, these solutions offer us the chance to streamline the authentication process and essentially alleviate the burden of managing it.

Therefore, this post is intended to be a precious beacon of light, showing you how to simplify the integration of AWS Cognito and AWS Amplify into your React TypeScript application, with a focus on SAML 2.0 integration with Identity Providers and enhancing REST API security using Bearer token authentication.

The authentication flow you’ll get

But first, before we look at the “how”, for full context, let’s walk through what this final authentication flow will look like:

  1. First, the authentication process is initiated directly within the frontend application. At this point, the frontend application takes charge and redirects the user to AWS Cognito, which acts as a centralized hub for all things authentication-related.
  2. Upon arriving at the AWS Cognito-hosted UI, the user is presented with a variety of authentication providers to choose from. (This can include options for SAML and SSO, offering flexibility.)
  3. After the user selects their desired authentication provider, they are taken to the third-party provider’s platform to complete the necessary authentication steps, ensuring their identity if properly verified and confirmed.
  4. After this, the user is redirected back to AWS Cognito; this step serves as a confirmation of the user’s identity. Cognito then seamlessly redirects the user back to the frontend application they initially started from.
  5. Now, it’s up to the frontend application to receive the authentication status from AWS Cognito and make the final call on whether the user is granted access or not.
  6. If the authentication is deemed successful, the user is given the green light to enter and interact with the frontend application. (This includes the ability to securely send requests to the backend, all while maintaining an authenticated and protected environment.)
Flowchart showing the authentication process from the frontend to AWS Cognito and back to the frontend

Authentication flow from the frontend to AWS Cognito and back

Note: When using Cognito + Amplify combination, there is a way to program the authentication UI when you call UI components from the Amplify library (authentication form, buttons, input fields, and so on) in your application code. This method can be suitable for you if you’re ready to take responsibility for the authentication UI yourself, instead of using Hosted UI from Cognito. In our case, we’ll use this integrated code inside our app, so we won’t deal with authorization UI at all, but pass this all responsibility onto Cognito and its Hosted UI.

Getting started

We begin by defining some essential environment variables:

COGNITO_CLIENT_ID=exampleClientId123
COGNITO_USER_POOL_ID=us-example-1_abcd1234
COGNITO_DOMAIN_NAME=example-pool.auth.us-example-1.amazoncognito.com

The cornerstones of this setup are really COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, and COGNITO_DOMAIN_NAME; these variables ensure the app can properly communicate with the AWS Cognito services.

You can find information about how to get these values in the AWS Cognito documentation:

To enforce a solid validation mechanism for these environment variables, we’ll turn to joi for schema validation. This step is critical to avoid directly using any raw values from import.meta.env. It also guarantees that all the necessary configuration settings will be present. We’ll start with some TypeScript interfaces to structure our application configuration:

import * as Joi from 'joi';

export interface ApplicationConfig {
  cognitoClientID: string;
  cognitoDomainName: string;
  cognitoUserPoolID: string;
}

Following this, we’ll craft a function that validates our configuration against the defined schema. This will make sure that all environment variables have been correctly set before going on with execution:

export const getAppConfig = (): ApplicationConfig => {
  const config = {
    cognitoClientID: import.meta.env.COGNITO_CLIENT_ID,
    cognitoDomainName: import.meta.env.COGNITO_DOMAIN_NAME,
    cognitoUserPoolID: import.meta.env.COGNITO_USER_POOL_ID,
  };

  const validationSchema = Joi.object<ApplicationConfig>({
    cognitoClientID: Joi.string().required(),
    cognitoDomainName: Joi.string().required(),
    cognitoUserPoolID: Joi.string().required(),
  });

  const { error, value } = validationSchema.validate(config, {
    abortEarly: true,
  });

  if (error) {
    throw new Error(
      `[Application Config]: Environment validation failed. Please review your .env file. Error: ${error.message}`,
    );
  }

  return value;
};

Using AWS Amplify

We need to bridge the gap between our frontend and Cognito. Luckily, with libraries like @aws-amplify/ui-react and aws-amplify, AWS Amplify offers a streamlined solution for integrating Cognito into modern web applications.

Now, designing a well-structured auth flow is essential for ensuring a smooth and secure user experience. But integrating an authentication system into a React app involves more than just entering credentials like userPoolId and userPoolClientId in the main entry point. The crucial step is crafting the application’s authentication flow, and this often utilizes the OAuth protocol.

OAuth offers a flexible, secure way to handle user authentication and authorization. With it, we can seamlessly integrate various identity providers while keeping control over an application’s user data access.

Successfully implementing OAuth requires specifying several parameters:

  • Domain: the base URL for authentication requests.
  • Redirect callbacks: the URLs where users are sent after authentication.
  • Response type: determines the OAuth flow to be used.
  • Scopes: defines the permissions requested from the user.

With the above in mind, here’s how the configuration actually looks in code:

import { Authenticator } from '@aws-amplify/ui-react';
import { Amplify } from 'aws-amplify';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { getAppConfig } from './config/get-app-config.ts';
import { PageLayout } from './pages/PageLayout';
import { AUTH_CALLBACK_PATH } from './stores/router.ts';

const AUTH_SCOPE = ['email openid profile'];
const REDIRECT_CALLBACK = `${window.location.origin}${AUTH_CALLBACK_PATH}`;

const appConfig = getAppConfig();

Amplify.configure({
  Auth: {
    Cognito: {
      loginWith: {
        oauth: {
          domain: appConfig.cognitoDomainName,
          redirectSignIn: [REDIRECT_CALLBACK],
          redirectSignOut: [],
          responseType: 'code',
          scopes: AUTH_SCOPE,
        },
      },
      userPoolClientId: appConfig.cognitoClientID,
      userPoolId: appConfig.cognitoUserPoolID,
    },
  },
});

When integrating Amplify, the Authenticator.Provider is essential for encapsulating the entire application. This makes sure that all components, especially those deeper in the hierarchy, can access and utilize authentication data efficiently:

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Authenticator.Provider>
      <PageLayout />
    </Authenticator.Provider>
  </React.StrictMode>,
);

Sign-in with AWS Amplify and React

With our approach, we’re using “Hosted UI” from Cognito–and it includes pages for signing up, signing in, confirming accounts, and so on:

import { signInWithRedirect } from 'aws-amplify/auth';
import { ReactElement } from 'react';

export const SignIn = (): ReactElement => {
  return <button onClick={signInWithRedirect} type="button">Sign in</button>;
};

In this code, the signInWithRedirect function from AWS Amplify is being used. Here, when the “Sign in” button is clicked, this function redirects the user to the “Hosted UI” for sign-in. Then, upon successful sign-in, the user is redirected back to the application.

Login screen with multiple sign-in options including corporate ID, social accounts and a traditional username and password form

AWS Cognito Hosted UI Example

Building a layout component

We must now create a layout component to handle user authentication statuses and route users accordingly. This component will be the central hub for the application’s content rendering. By incorporating useAuthenticator with Nano Stores’ $router, we gain the ability to dynamically adjust navigation and component rendering according to authentication status and the current route in the application:

import { FC } from 'react';
import { AppRouter } from '../components/AppRouter.tsx';
import AuthCallback from './AuthCallback.tsx';
import SignIn from './SignIn.tsx';

export const PageLayout: FC = () => {
  const page = useStore($router);
  const { authStatus } = useAuthenticator((context) => [context.authStatus]);

  // For unauthenticated users trying to access the index page
  if (authStatus === 'unauthenticated' && page?.route === 'index') {
    return <SignIn />;
  }

  // Handling special routes or unauthenticated access
  if (
    authStatus === 'unauthenticated' ||
    page?.route === 'authCallback' ||
    page?.route === 'index'
  ) {
    return <AuthCallback />;
  }

  // Authenticated users can access the main app content
  return <main><AppRouter /></main>;
};

A few quick notes about the code above:

  • Unauthenticated users attempting to access the index route are prompted to sign in via the SignIn component.
  • The AuthCallback component is used to handle authentication callbacks or for directing unauthenticated users, making for smooth transitions.
  • Successfully authenticated users are directed to the app’s main content via AppRouter.

Crafting authentication flow test cases

Now that we’ve covered these basic operations, let’s talk about something critical for any authentication flow: testing. We’ll use React’s testing library and Vitest, and we’ll focus on the PageLayout component’s dynamic response to authentication status and routing.

We start by mocking AWS Amplify’s useAuthenticator hook and components involved in authentication decisions to simulate user interactions:

import { useAuthenticator } from '@aws-amplify/ui-react';
import { openPage } from '@nanostores/router';
import { render } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { $router } from '../stores/router.ts';
import AuthCallback from './AuthCallback.tsx';
import { PageLayout } from './PageLayout.tsx';
import SignIn from './SignIn.tsx';

vi.mock('@aws-amplify/ui-react');
vi.mock('./AuthCallback.tsx', () => ({ default: vi.fn() }));
vi.mock('./SignIn.tsx', () => ({ default: vi.fn() }));

This setup allows for a controlled testing environment, mimicking real-world scenarios within the application. Our first test will check how PageLayout reacts to an unauthenticated user trying to access the index route:

test('If authStatus unauthenticated and page route signIn, render SignIn', () => {
  vi.mocked(useAuthenticator).mockReturnValue({
    authStatus: 'unauthenticated',
  });

  openPage($router, 'index');

  render(<PageLayout />);

  expect(SignIn).toHaveBeenCalled();
});

This should verify that any unauthenticated users are directed to the SignIn component, meaning that the app secures entry points that would require authentication.

Moving on, this next test examines the app’s behavior when routing to authCallback:

test('If page route authCallback, render AuthCallback', () => {
  vi.mocked(useAuthenticator).mockReturnValue({
    authStatus: 'unauthenticated',
    signOut: vi.fn(),
  });

  openPage($router, 'authCallback');

  render(<PageLayout />);

  expect(AuthCallback).toHaveBeenCalled();
});

Above, we assess the PageLayout component’s ability to correctly render the AuthCallback component based on routing.

Preparing the AuthCallback component for handling post-authentication

Handling the post-authentication phase is another important part of the overall user authentication process. The AuthCallback component is key to managing what happens next (depending on the user’s authentication status as determined by AWS Amplify’s useAuthenticator hook).

Within the AuthCallback component, let’s create some logic to handle user redirection based on authentication status:

export const AuthCallback = (): ReactElement => {
  const { authStatus, signOut } = useAuthenticator((context) => [
    context.authStatus,
    context.signOut,
  ]);

  useEffect(() => {
    if (authStatus === 'unauthenticated') {
      redirectPage($router, 'index');
    }
  }, [authStatus]);
}

This ensures that unauthenticated users are redirected back to the index page, preventing access as appropriate and thereby safeguarding application security.

In terms of UX, to keep users informed during the authentication process, we can employ a visual feedback mechanism:

{
  return (
    <Loader />
  );
};

AuthCallback component testing

We also need to make sure that users are directed as they should be based on their authentication status. That’s where testing the AuthCallback component comes into play. To do that, first, we prepare our testing environment by mocking dependencies:

import { useAuthenticator } from '@aws-amplify/ui-react';
import { redirectPage } from '@nanostores/router';
import { render } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import AuthCallback from './AuthCallback.tsx';

vi.mock('@aws-amplify/ui-react');
vi.mock('@nanostores/router', async (importOriginal) => {
  const mod = await importOriginal<typeof import('@nanostores/router')>();
  return {
    ...mod,
    redirectPage: vi.fn(),
  };
});

We want our tests to focus solely on the components logic, so, here, @aws-amplify/ui-react and @nanostores/router are mocked to isolate the test environment, allowing us to control the behavior of these libraries.

Next, since we want to make sure users are being correctly guiding to the necessary entry point, this first test case checks if unauthenticated users are redirected to the index page:

test('If authStatus unauthenticated and page route signIn, redirect to SignIn', () => {
  vi.mocked(useAuthenticator).mockReturnValue({
    authStatus: 'unauthenticated',
    signOut: vi.fn(),
  });

  render(<AuthCallback />);

  expect(redirectPage).toHaveBeenCalledWith(expect.anything(), 'index');
});

Our second test checks that, under unauthenticated conditions, a user isn’t mistakenly redirected to unnecessary pages, like home-page:

test('If authStatus unauthenticated and user attempts to sign in, do not render Home Page', () => {
  vi.mocked(useAuthenticator).mockReturnValue({
    authStatus: 'unauthenticated',
    signOut: vi.fn(),
  });

  render(<AuthCallback />);

  expect(redirectPage).not.toHaveBeenCalledWith(
    expect.anything(),
    'home-page',
  );
});

Breaking down the AppRouter component

The AppRouter component is the nerve center of the app’s navigation system. Let’s dissect this component a bit in order to understand its functionality and importance:

import { useAuthenticator } from '@aws-amplify/ui-react';
import { useStore } from '@nanostores/react';
import { FC, Suspense } from 'react';
import { $router } from '../stores/router.ts';
import { Routes } from './Routes.tsx';
import { Spinner } from './Spinner.tsx';

export const AppRouter: FC = () => {
  const page = useStore($router);
  const { authStatus } = useAuthenticator((context) => [context.authStatus]);

  if (authStatus === 'authenticated' && page?.route !== 'index' && page) {
    return (
      <Suspense
        fallback={<Spinner />}
      >
        <Routes page={page} />
      </Suspense>
    );
  }

  return 'Not found';
};

The core logic begins with fetching the current authStatus and the requested page. Then, for authenticated users seeking a route other than index, the component decides the content to render:

  1. Authenticated + Valid Route: In this case, the Routes component is rendered within a Suspense block, ensuring any lazy-loaded components have a smooth fallback-the Spinner. This setup not only enhances UX with immediate feedback during load times, it also maintains a clean separation of concerns between routing logic and page content.
  2. Unauthenticated / Invalid Route: If a route is invalid or not found, a simple Not found message is displayed, guiding users back to the navigation flow without exposing any unintended content.

Integrating authentication with fetch operations

Finally, in modern web applications, securing API requests is crucial. The fetcher function we’ve designed has been tailor-made to include authentication data (specifically, a token from AWS Cognito) in the headers of each request sent to the server. Let’s break down the components and the rationale behind this approach.

First, we establish a type for our request parameters, thus making sure we have both HTTP method and payload type flexibility:

type RequestParams<T> = {
  data?: T;
  method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
  path: string;
};

Then, the core of the fetcher function fetches the authentication session, including the token in the request headers:

const cognitoTokens = (await fetchAuthSession()).tokens;
const rawToken = cognitoTokens?.idToken?.toString();

Securely fetching Cognito tokens and appending them as a Bearer token in the request headers makes every server communication authenticated, protecting data integrity and privacy.

Next, the request is configured with the necessary headers and method, using the Request constructor for a clean and explicit request setup:

const request = new Request(path, {
  body,
  headers: {
    Authorization: 'Bearer ' + rawToken,
    'Content-Type': 'application/json',
  },
  method,
});

The function then processes the response, gracefully handling the success and error scenarios:

const response = await fetch(request);
const result: R = await response.json();

if (!response.ok) {
  const message = 'message' in result ? result.message : response.statusText;

  throw new Error(message);
}

return result;

And there we have it. This fetcher function acts as a comprehensive solution for secure, efficient API communication within a React app.

Over and out: going incognito

At first blush, the prospect of implementing AWS Cognito authentication in a React app might seem like an excuse to run to the pharmacy and get some headache medication. That said, hopefully the techniques outlined in this guide have inspired you to get in the ring and fight on behalf of security, usability, and scalability!

At Evil Martians, we transform growth-stage startups into unicorns, build developer tools, and create open source products. If you’re ready to engage warp drive, give us a shout!

Solve your problems with 1-1 guidance

Hop on a 50-minute call for customized advice on improving performance, scaling, product shipping, UI design, or cost-effective deployment. Our experts in developer-first startups, offer tailored recommendations for you—for free!

Reserve your spot
Launch with Martians

In the same orbit

How can we help you?

Martians at a glance
17
years in business

We transform growth-stage startups into unicorns, build developer tools, and create open source products.

If you prefer email, write to us at surrender@evilmartians.com