At Xtages, we use Amazon Cognito to manage our users and their authentication.
Cognito collects a user’s attributes, it enables simple, secure user authentication, authorization and user management for web and mobile apps.
Overview of Cognito
In this section, we’ll take a 5000 feet view of how Cognito integrates with a web app.
Cognito requires users to verify their email or phone number to enable password recovery flow. The way that verification
works is by sending a numeric code to the user’s email or phone number (using SMS) and the user the app then calling
CognitoAuth.confirmSignUp
like step #6 of the diagram above.
Next, we’ll see how to:
- Create a new React webapp using
create-react-app
- Add a
useAuth
hook and<AuthProvider>
Context
- Create authenticated and unauthenticated
Route
s - Provision a Cognito user pool using Terraform
Integration with React
We can use Cognito to secure certain sections of our React app, making sure that only authenticated users have access to them.
Setup
We’ll start by creating a new React web app using create-react-app
:
npx create-react-app demo-app --template typescript
useAuth
hook
The core piece of this integration is the useAuth
hook which is based on https://usehooks.com/useAuth/
and adapted to use Cognito’s API.
After installing the Cognito API package from Amazon, and the use-async-effect
package
npm install --save @aws-amplify/auth use-async-effect
we’ll go ahead and create a new file for our useAuth
hook under src/hooks/
and we’ll call it useAuth.tsx
.
Next, we’ll configure the Cognito Auth object with the AWS Region, Cognito User Pool Id and Cognito Web Client Id. You can see these values are being included from environment variables, so it’s easy to configure our app depending on where it runs (local development, continuous integration, staging, production, etc.).
Now let’s create some types for our hook’s API:
The User
type has the properties we expect for a user. The User.country
property is an example of a
Cognito custom attribute
.
The Credentials
type will be used in our logIn
and signUp
functions.
Notably the CognitoUserWithChallenge
is a bit of a crutch that we need, to make the Typescript compiler happy. The
object returned by the CognitoAuth.signIn
function (from the amazon-cognito-identity-js
package)
can contain a property called challengeName
however in the typings for the CognitoAuth.signIn
that property is not
present therefore we have to supplement the type in our code. The challengeName
property is used to convey that the
user needs to respond to a challenge to verify their identity. So for example when the user signs up they are emailed a
code that they must input as a challenge response. For more information
see https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html
Finally, we’ll get to the implementation of our hook. In reality, we have a private hook useProvideAuth
and
a Context
and all are exposed through the public useAuth
hook.
First, we’ll dissect the useProvideAuth
private hook which has the bulk of the code:
The useProvideAuth
hook has three public functions:
logIn
: wrapsCognitoAuth.signIn
and if the result of the call has achallengeName
returns that, otherwise converts the returnedCognitoUser
sets theuser
state and also toggles theauthInProgress
state to false.logOut
: sets theuser
state tonull
and callsCognitoAuth.signOut
to invalidate the user’s token.signUp
: callsCognitoAuth.signUp
with the required user attributes plus their password.
A couple of other interesting points about this hook is the use of useAsyncEffect
(from the use-async-effect
package) which has the same semantics of plain useEffect
but allows for using async
functions. We need this hook
because we determine, on render, if the user is logged in by calling the getUser
function which in itself is
async
. We also set a listener through the AWS Hub
class which will notify us of different auth-related events that
have occurred.
Now that our hook is ready, we’ll go ahead and set up a Context
so our app can take advantage of this infrastructure:
In the snippet above, we create a Context
of type Auth | null
, where Auth
is ReturnType<typeof useProvideAuth>
,
we also created a tiny component <AuthProvider>
which passes an instance of Auth
to the <AuthContext.Provider>
.
Finally useAuth
wraps useContext(AuthContext)
and ensures that the returned Auth
is never null
.
Usage
After having the auth infrastructure in place, we now need to start using in our app, the first step is to wrap our
component hierarchy in an <AuthProvider>
, like such:
You’ll notice a few points in the snippet above:
- we are using
react-router-dom
for our navigation. - we have wrapped our app in the
<AuthProvider>
that we just created, which means that theuseAuth
hook is now usable to all our components. - we have a couple of utility route components that are not part of
react-router-dom
, namely<UnauthenticatedOnlyRoute>
and<AuthenticatedRoute>
.
Secure routing
Below, <AuthenticatedRoute>
is using useAuth
to determine if the authentication is in progress (and if so display
an empty page), if we have an authenticated user we then route to the specified component and if we don’t have an
authenticated user then we redirect to the /login
page, preserving the location
in the state, so we can redirect to
it once the user successfully logs in.
<UnauthenticatedOnlyRoute>
is basically a mirror of <AuthenticatedRoute>
in that it will only render the Route
if the user is not
authenticated, redirecting to /
otherwise.
Login page
The following is a very simple log-in page, for illustration purposes only, using the useAuth
hook, although you’ll
probably want to use a proper react form library and maybe some kind of UI library too.
Using Terraform to provision Cognito
The following Terraform module was used to provision the Cognito user pool used in this blog post:
Some interesting sections of this module:
- Lines 24-34: The
password_policy
block is where we configure the requirements of a valid password. As a tip for better UX, make sure that your signup page form, validates the user’s password client-side using the same requirements. You can also leverage a package likereact-password-strength-bar
to nudge the user to create strong passwords. - Line 37-73: The
schema
block is where we define attributes for the user’s profile. Custom attributes must use thecustom:
prefix on their names. - Line 80: The
username_attributes
array indicates that we are using theemail
attribute as the username for our users.
The aws_cognito_user_pool_client
resource declares a web client for our user pool, which is, you guessed it, our React
app. This is where we configure the validity periods for the tokens returned by Cognito in lines 104-111. Line
119, generate_secret = false
ensures that a secret is not generated for this client which is necessary because the
browser Cognito js library doesn’t support secrets.
Lines 123-134 indicate which attributes, as defined in the user pool itself, are readable and/or writable by the web client.
Conclusion
AWS Cognito is a good option when it comes to user authentication and management. It’s specially useful when you are already bought into the AWS ecosystem and it’s also cheaper than some other alternatives.
It’s not too difficult to integrate React and Cognito, however Cognito’s reference documentation for their
Javascript/Typescript package lacks some in-depth detail, they also have several seemingly overlapping packages
(amplify
, @aws-amplify/auth
and amazon-cognito-identity-js
) that make it hard to pinpoint exactly which one
is necessary for Cognito to work. Amazon also tends to push Amplify as a whole
for the auth solution when it’s not really necessary to add all that code to your app.
At Xtages we are building an all-in-one solution for CI/CD and hosting of apps, all with minimal configuration and no infrastructure to manage. We recently introduced a free plan (no credit card required) to make it easier to get started.