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.
But first, before we look at the “how”, for full context, let’s walk through what this final authentication flow will look like:
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.
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;
};
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:
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>,
);
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.
AWS Cognito Hosted UI Example
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:
index
route are prompted to sign in via the SignIn
component.AuthCallback
component is used to handle authentication callbacks or for directing unauthenticated users, making for smooth transitions.AppRouter
.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.
AuthCallback
component for handling post-authenticationHandling 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 testingWe 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',
);
});
AppRouter
componentThe 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:
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.Not found
message is displayed, guiding users back to the navigation flow without exposing any unintended content.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.
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!