Remix Authentication with Amazon Cognito
Published: 2024-07-17 22:24:06

## Introduction

[Remix](https://remix.run/) is a powerful React-based web framework, and it offers a lot of benefits to developers and users alike. There are trade-offs of course, and the framework's small ecosystem and philosophy of remaining unopinionated can make it difficult to know where to turn when implementing features like authentication within the context of server-side rendering (SSR). I've had the pleasure of using Remix at two separate jobs and I'm happy to say that there are good strategies for navigating these challenges.

## The Problem

Many guides for integrating Amazon's Cognito service recommend using AWS's Amplify [library](https://aws.amazon.com/amplify/). While Amplify works well for the traditional, client-side rendered single-page application (SPA), it doesn't yet support newer SSR paradigms. At the time of this writing, AWS Amplify doesn't support SSR in Remix [source](https://github.com/aws-amplify/amplify-js/issues/9362), though Amplify's Hosting service recently [added support](https://docs.aws.amazon.com/amplify/latest/userguide/ssr-Amplify-support.html) for SSR in Next versions 12 and greater. While you can use Amplify's React SDK in your Remix application on the client, you will be losing some of the benefits of SSR.

## TLDR

Use Remix-Auth and the OAuth2 strategy to set up an Authenticator instance with our Amazon Cognito User Pool and App Client information. This Authenticator instance will help us manage the granting, storing, and revocation of session tokens stored in HTTP cookies, enabling users to login to our application using a Cognito hosted UI.

## The Solution

Utilizing Amazon Cognito's hosted UI, we can leverage existing libraries to implement custom handlers in Remix while retaining the advantages of SSR.

### Cognito

#### User Pool

In the AWS console UI, navigate to Amazon Cognito and select "User pools". [Create one](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-as-user-directory.html) if you don't already have an existing user pool to use. There are many configuration options for setting up a user pool so you'll have to assess what's right for you and your app. In this example, we're building an authentication process that doesn't currently support self-registration or MFA, so keep that in mind.

Now that you have a user pool, select the "App Integration" option in the AWS console and look for "Cognito Domain". Copy this value and store it in your .env file. I stored it as `COGNITO_USER_POOL_URL` in the code below.

#### App Client

Next, we'll need an app client for our hosted UI. If you don't already have one, follow [these instructions](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-configuring-app-integration.html) to set one up. Ignore the step that tells you not to generate a client secret. We will need a client secret here so be sure to generate it.

Once you've created the app client, store the client id and client secret in your .env as well. The last thing we'll need is the client callback url. This should've been something you set up when you created the app client. You can access this information after creation by selecting Edit on your hosted UI. Look for the section "Allowed callback URLs" in the Edit screen.

Now that we have all of that information. Here's how it looks:

```env
COGNITO_USER_POOL_URL="https://samlindstrom.auth.us-west-2.amazoncognito.com"
COGNITO_APP_CLIENT_ID="<INSERT YOUR ID>"
COGNITO_APP_CLIENT_SECRET="<INSERT YOUR SECRET>"
COGNITO_APP_CLIENT_CALLBACK_URL="http://localhost:3000/auth/callback"
```

Note: Variables that include URLs as a reference back to our Remix application should be conditionally set based on the environment. Example: we wouldn't want the above `COGNITO_APP_CLIENT_CALLBACK_URL` set as localhost domain in a production environment. Instead, we'd want to do something like this:

```typescript
COGNITO_APP_CLIENT_CALLBACK_URL = process.env.NODE_ENV === "production" ? `${<YOUR PROD DOMAIN>}/auth/callback` : "http://localhost:3000/auth/callback"
```

### Remix Auth

Once our Cognito configuration details are retrieved and stored, we'll want to begin implementing our Authenticator instance. The library we'll use to make this process simple is the excellent [Remix-Auth](https://github.com/sergiodxa/remix-auth) from Sergio Xalambrí. Remix Auth offers [numerous strategies](https://github.com/sergiodxa/remix-auth/discussions/111) for handling authentication in a Remix application. We'll be using the OAuth2 [strategy](https://github.com/sergiodxa/remix-auth-oauth2) here.

#### Adding the OAuth2 Strategy

1. Install Remix Auth
1. Establish Session Storage
1. Create an authenticator instance in your Remix application (`auth.server.ts` in our case)
1. Create callback, login, and logout routes
1. Configure Cognito

##### Installation

`npm add remix-auth-oauth2` or equivalent command in your package manager or choice.

##### Session Storage

First, let's start by setting up our session storage object in Remix. There are [numerous techniques]( for managing user session storage in Remix, but we'll be using cookie-based sessions today. See the Remix [sessions docs](https://remix.run/docs/en/1.19.3/utils/sessions) if you'd like more information.

```typescript
// app/session.server.ts
import { createCookieSessionStorage } from "@remix-run/node";

// create the cookie-based session storage and export it
export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "_session", // Cookie Name: any name will do here
    sameSite: "lax", // Helps with CSRF protection 
    path: "/", // Remember to add this so the cookie will be available on all routes
    httpOnly: true, // For security reasons, make this cookie http only so that it's inaccessible to JavaScript
    secrets: ["secret-value"], // Replace this with an actual strong secret
    secure: process.env.NODE_ENV === "production", // enable this in prod only
  },
});

// Export session methods for use in other parts of the application
export const { getSession, commitSession, destroySession } = sessionStorage;
```

##### Create Authenticator Instance

```typescript
// app/auth.server.ts

import { redirect } from "@remix-run/node";
import type { JwtPayload } from "jwt-decode";
import jwtDecode from "jwt-decode";
import { Authenticator } from "remix-auth";
import { OAuth2Strategy } from "remix-auth-oauth2";

import { sessionStorage } from "../session.server";

const {
  COGNITO_APP_CLIENT_CALLBACK_URL,
  COGNITO_USER_POOL_URL,
  COGNITO_APP_CLIENT_ID = "",
  COGNITO_APP_CLIENT_SECRET = "",
} = process.env;

// Define the type for authenticated user data
type AuthenticatedUser = {
  accessToken: string;
  tokenExpiry?: number;
  name?: string;
  email?: string;
  email_verified?: string;
};

// Define the type for user info retrieved from Cognito
// Derived from https://docs.aws.amazon.com/cognito/latest/developerguide/userinfo-endpoint.html
type UserInfo = {
  email: string;
  email_verified: string;
  family_name: string;
  given_name: string;
  identities: unknown[];
  name: string;
  preferred_username: string;
  sub: string;
  username: string;
};

// Define the type for decoded JWT token
interface DecodedToken extends JwtPayload {
  auth_time: number;
  username: string;
}

// Initialize the authenticator with session storage
export const authenticator = new Authenticator<AuthenticatedUser>(
  sessionStorage
);

// Configure OAuth2 strategy with Cognito details
const OAuthStrategy = new OAuth2Strategy(
  {
    clientID: COGNITO_APP_CLIENT_ID,
    clientSecret: COGNITO_APP_CLIENT_SECRET,
    callbackURL: COGNITO_APP_CLIENT_CALLBACK_URL || "",
    authorizationURL: `${COGNITO_USER_POOL_URL}/oauth2/authorize`, // Read https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html
    tokenURL: `${COGNITO_USER_POOL_URL}/oauth2/token`, // Cognito token endpoint
    useBasicAuthenticationHeader: false, // defaults to false
  },
  async ({ accessToken }): Promise<AuthenticatedUser> => {
    // Decode the JWT token to get user details
    const decoded = jwtDecode<DecodedToken>(accessToken);

    // Fetch user info from cognito and include in authenticator response
    // https://docs.aws.amazon.com/cognito/latest/developerguide/userinfo-endpoint.html
    let response;
    try {
      response = await fetch(`${COGNITO_USER_POOL_URL}/oauth2/userInfo`, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ContentType: "application/json",
        },
        method: "GET",
      });
    } catch (e) {
      console.error("There was a problem fetching user info: ", e);
    }

    const info = response ? ((await response.json()) as UserInfo) : null;

    // Return authenticated user details
    return {
      accessToken,
      tokenExpiry: decoded.exp,
      name: info?.name,
      email: info?.email,
      email_verified: info?.email_verified,
    };
  }
);
```

#### Set up routes in Remix App

Next we'll need to establish routes in our Remix application to handle logout and callback events.
To do this, we'll set up [resource routes](https://remix.run/docs/en/main/guides/resource-routes) to facilitate communication between our Cognito hosted UI and our application.

See Amazon's Hosted UI endpoint reference [doc](https://docs.aws.amazon.com/cognito/latest/developerguide/hosted-UI-endpoints.html) for more details.

##### Logout Resource Route

See Cognito [documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/logout-endpoint.html) for further reference.

```typescript
// app/routes/auth.logout.ts

import type { ActionFunction, LoaderFunction } from '@remix-run/node';
import { redirect } from '@remix-run/node';

import { destroySession, getSession } from '../session.server';

const {
  COGNITO_APP_CLIENT_ID = '',
  COGNITO_USER_POOL_URL
} = process.env;

// Distinguish between dev and prod envs for your applications url
const clientURL = process.env.NODE_ENV === "production" ? <YOUR_APP_DOMAIN> : "http://localhost:3000"

const handleLogout = async (request: Request) => {
  // Get the current session
  const session = await getSession(request.headers.get('Cookie'));

  const cognitoLogoutUrl = new URL(`${COGNITO_USER_POOL_URL}/logout`);

  // Set required parameters for Cognito logout URL
  // These values should correspond with configured settings in your app client
  cognitoLogoutUrl.searchParams.set(
    'client_id',
    COGNITO_APP_CLIENT_ID
  );
  cognitoLogoutUrl.searchParams.set(
    'logout_uri',
    `${clientURL}/auth/logout`
  );
  cognitoLogoutUrl.searchParams.set('response_type', 'code');

  // Redirect to Cognito logout URL and destroy session
  return redirect(cognitoLogoutUrl.toString(), {
    headers: {
      'Set-Cookie': await destroySession(session)
    }
  });
};

// Define loader and action functions for handling logout
export const loader: LoaderFunction = async ({ request }) => {
  return await handleLogout(request);
};

export const action: ActionFunction = async ({ request }) => {
  return await handleLogout(request);
};

```

Now you can add a logout button in your application which will invoke the above `loader`. We've also specified an `action` above in case the route receives a request that doesn't include an HTTP "GET" method.

```typescript
import { Link } from '@remix-run/react';

...

<Link to="/auth/logout">
  <button>Logout</button>
</Link>
```

##### Callback Resource Route

```typescript
// app/routes/auth.callback.ts

import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { authenticator } from "../auth.server";
import { commitSession, getSession } from "../session.server";

export const loader: LoaderFunction = async ({ request }) => {
  const user = await authenticator.authenticate("oauth2", request);

  // Manually get the current session
  const session = await getSession(request.headers.get("Cookie"));

  // Store authenticated user details in session
  session.set(authenticator.sessionKey as "user", user);

  const headers = new Headers({ "Set-Cookie": await commitSession(session) });

  // Redirect to the application root with updated session
  return redirect("/", { headers });
};
```

##### Logout UI

```typescript
import { Link } from "@remix-run/react";

import { routes } from "~/routes";

export default function LogoutPage() {
  return (
    <div>
      <h1>You have successfully logged out</h1>
      <Link to={routes.root()}>Go Home</Link>
    </div>
  );
}
```

##### Authenticate at Application Root

In your Remix app's root loader, you can now do the following

```typescript
// app/root.tsx

import { json, type LoaderArgs } from '@remix-run/node';
import { authenticator, checkTokenExpiry } from './auth.server';

...

export const loader = async ({ request }: LoaderArgs) => {
  const user = await authenticator.authenticate('oauth2', request);

  // Return authenticated user details as JSON
  return json({
    user
  });
};
```

Alternatively, if you don't intend to authenticate at your application's root, you can avoid adding the `authenticate` call above and selectively add it to specific route loaders as a more targeted authentication approach. This is the approach you'll need to take if you intend to redirect a user back to a custom logout page.

###### What about our Login page?

Thankfully this is taken care of by Remix-Auth and our Cognito hosted UI. Upon visiting the application root, you should be redirected to the Cognito login form (hosted UI). If you already have a user set up in your user pool, login using those credentials otherwise set up a user in the AWS console and then login to test this new functionality. Once you successfully login, your application should now be able to access `user` details via the root loader.

#### Extra Credit

- Create a function to check token expiration and invoke it at application root.
- Consider adding type safety to your session storage objects using Sergio's [remix-utils library](https://github.com/sergiodxa/remix-utils#typed-sessions).