Social Login With Cognito and NextAuth
Tags: cognito, nextjs, aws, authentication
Published: 2023-02-24 20:23:16

AWS Cognito user pools allow you to manage your app's within the AWS ecosystem. It provides all the basic features you'd expect from an auth system.

When I'm building an application on AWS infrastructure, I prefer using Cognito user pools due to its seamless integration with other AWS services such as API Gateway authorization.

Cognito user pools also allow you to set up social login for your users. This is very convenient in a world where most people have become accustomed to authentication via existing accounts such as Google, Facebook, Apple, etc.

In this article, I will be covering how to integrate social login in your Next.js applications through Cognito. We will essentially allow our users to log in using their social accounts while using Cognito as a proxy auth server.

We will do this while bypassing the Cognito-hosted UI for a seamless auth experience for the user.

There are a few benefits to using this approach instead of manually handling social logins ourselves:

1. We can leverage Cognito user pools and all its rich features.
    
2. In our Next.js application, we only have to deal with 1 auth provider, Cognito.
    
3. We have the freedom to design our social sign-in buttons as we please, without being shackled by [Cognito's hosted UI](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-app-integration.html).
    
4. We do not have to manage a database of users ourselves.
    

### Prerequisites

This article will not go over the process of setting up your Cognito user pool. Before you implement the steps in this article, make sure you have:

1. A Cognito user pool.
    
2. An app client set up for your user pool.
    
3. Social login setup for your app client. You don't need to set up all the social providers, just one of them is enough. You can always plug more providers in later.
    
4. A domain for your user pool. It does not need to be a custom domain. The default domain provided by aws is sufficient.
    

Our auth structure should roughly look like this:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1676536429753/158849cb-ee26-4766-8523-7ccbd3f6445d.png align="center")

### Setting up NextAuth

In the Next.js application, first, install the [NextAuth](https://next-auth.js.org/) library.

```plaintext
npm install next-auth
```

Next, set up the NextAuth configurations in `/api/auth/[...nextauth].ts`.

```typescript
import { TokenSet } from "next-auth";
import NextAuth from "next-auth/next";
import { Provider } from "next-auth/providers";

const { 
  NEXTAUTH_URL,
  COGNITO_REGION,
  COGNITO_DOMAIN,
  COGNITO_CLIENT_ID,
  COGNITO_USER_POOL_ID,
  COGNITO_CLIENT_SECRET,
} = process.env;

type TProvider = "Amazon" | "Apple" | "Facebook" | "Google";

function getProvider(provider: TProvider): Provider {
   /*
      Provider generation function to avoid repeating ourselved when
      declaring providers in the authOptions below.
   */
  return {
    // e.g. cognito_google | cognito_facebook
    id: `cognito_${provider.toLowerCase()}`,  
    
    // e.g. CognitoGoogle | CognitoFacebook
    name: `Cognito${provider}`,
    
    type: "oauth",

    // The id of the app client configured in the user pool.
    clientId: COGNITO_CLIENT_ID,

    // The app client secret.
    clientSecret: COGNITO_CLIENT_SECRET,
    
    wellKnown: `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID}/.well-known/openid-configuration`,
    
    // Authorization endpoint configuration
    authorization: {
      url: `${COGNITO_DOMAIN}/oauth2/authorize`,
      params: {
        response_type: "code",
        client_id: COGNITO_CLIENT_ID,
        identity_provider: provider,
        redirect_uri: `${NEXTAUTH_URL}/api/auth/callback/cognito_${provider.toLowerCase()}`
      }
    },

    // Token endpoint configuration
    token: {
      url: `${COGNITO_DOMAIN}/oauth2/token`,
      params: {
        grant_type: "authorization_code",
        client_id: COGNITO_CLIENT_ID,
        client_secret: COGNITO_CLIENT_SECRET,
        redirect_uri: `${NEXTAUTH_URL}/api/auth/callback/cognito_${provider.toLowerCase()}`
      }
    },

    // userInfo endpoint configuration
    userinfo: {
      url: `${COGNITO_DOMAIN}/oauth2/userInfo`,
    },

    // Profile to return after successcul auth.
    // You can do some transformation on your profile object here.
    profile: function(profile) {
      return {
        id: profile.sub,
        ...profile
      }
    }
  }
}

export const authOptions = {
    
  /*
    Generate the providers for each of our social login.
    Adding a new OIDC provider is a simple as adding the name of the         provider as displayed in the Cognito user pool to the TProvider type
and to this array.
  */
  providers: [
    ...["Amazon", "Apple", "Facebook", "Google"].map((provider: TProvider) => getProvider(provider)),
  ],

  callbacks: {
    async signIn({ user, account, profile }) {
      // Return true to allow sign in and false to block sign in.
      return true;
    },
    async redirect({ url, baseUrl }){
      // Return the url to redirect to after successful sign in.
      return baseUrl;
    },
    async jwt({ token, account, profile, user }){
      // Retrieve jwt tokens
      if (account) {
        // account is provided upon the inital auth
        return {
          ...token,
          accessToken: account.access_token,
          idToken: account.id_token,
        }
      }
    },
    async session({ session, token }) {
      /* 
         Forward tokens to client in case you need to make authorized
         API calls to an AWS service directly from the front end.
      */
      session.accessToken = token.accessToken;
      session.idToken = token.idToken;
      return session;
    }
  }
};

export default NextAuth(authOptions);
```

As you can see, we're configuring a basic OAuth2.0 flow. You can read more about Cognito's OIDC endpoints [here](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html). This flow will work with any OIDC provider that you configure in your user pool.

### Callback enhancement

Right now there's a scenario we're not handling. When authenticating in this way, Cognito will return a long-lasting refresh token. A refresh token is used to request new access and id tokens when said tokens have expired.

This allows us to keep the user logged in for a long time without forcing them to sign in every time their tokens expire. We will only prompt the user to sign in again once the refresh token itself has expired or has been invalidated in some other way.

To support this functionality, we need to update the code in the `jwt` and `session` callback functions.

First, let's update the `jwt` callback function:

```typescript
async jwt({ token, account, profile, user }){
  if (account) {
    // This is an initial login, set JWT tokens.
    return {
      ...token,
      accessToken: account.access_token,
      idToken: account.id_token,
      refreshToken: account.refresh_token,
      expiresAt: account.expires_at,
      tokenType: 'Bearer'
    }
  }
  if (Date.now() < token.expiresAt) {
    // Access/Id token are still valid, return them as is.
    return token;
  }
  // Access/Id tokens have expired, retrieve new tokens using the 
  // refresh token
  try {
    const response = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
      headers: { 
        "Content-Type": "application/x-www-form-urlencoded"
      },
      body: new URLSearchParams({
        client_id: COGNITO_CLIENT_ID,
        client_secret: COGNITO_CLIENT_SECRET,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken
      }),
      method: "POST"
    })

    const tokens: TokenSet = await response.json();

    if (!response.ok) throw tokens;

    return {
      ...token,
      accessToken: tokens.access_token,
      idToken: tokens.id_token,
      expiresAt: Date.now() + (Number(tokens.expires_in) * 1000)
    }
  } catch (error) {
    // Could not refresh tokens, return error
    console.error("Error refreshing access and id tokens: ", error);
    return { ...token, error: "RefreshTokensError" as const }
  }
}
```

Here's what this function is doing before returning the jwt tokens when a token request is made:

1. If it's the first request (i.e initial sign-in action), add `accessToken`, `idToken`, `refreshToken`, and `expiresAt` to the token object. The `expiresAt` attribute lets us know when the access and id token will expire. The object returned here will be represented by the `token` param in all subsequent token requests.
    
2. If this is not the first request, check if the access and id tokens have expired by comparing the current time to the `expiresAt` time in the token object that was set in point 1.
    
3. If the tokens have not expired, return the same token object.
    
4. If the tokens have expired, fetch new tokens using the refresh token.
    
5. If the fetch fails, throw the error. In the catch block, return the current token object but append an `error` property so we can check for it and redirect the user to the sign-in page.
    
6. If the fetch is successful, replace the `accessToken`, `idToken` and `expiresAt` properties in the token object and return the new token object.
    

Next, we need to update the `session` callback. This callback allows us to set session properties that will be accessible in the front end via the `useSession` hook.

All we need to do here is add the `error` property of the token to the session object. That way we can detect the error in the front end and redirect the user to the sign-in page.

```typescript
async session({ session, token }) {
  /* 
    Forward tokens to client in case you need to make authorized API      
    calls to an AWS service directly from the front end.
  */
  session.accessToken = token.accessToken;
  session.idToken = token.idToken;
  /* 
    If there is an error when refreshing tokens, include it so it can 
    be forwarded to the front end.
  */
  session.error = token.error;
  return session;
}
```

### Configuring shared session state

To configure the sessions state, update the `_app.tsx` *or* `_app.jsx` file with the following code:

```typescript
import '../styles/globals.css';
import { SessionProvider } from "next-auth/react";

function MyApp({ 
  Component,
  pageProps: { session, ...pageProps }
}) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  )
}

export default MyApp
```

This change allows us to use the session throughout the application.

### Frontend login with providers

In order to allow the user to sign in with their preferred provider, we have to configure the sign-in buttons for each provider in our SignIn page component.

```typescript
import { useEffect, useState } from "react";

/* The getProviders function returns a promise that resolves
   to an object containing all of the configured providers. */
// The signIn functions initiates the signIn process.
import { getProviders, signIn } from "next-auth/react";

export default function SignIn() {
  const [providers, setProviders] = useState(null);

  // Fetch providers on mount.
  useEffect(() => {
    getProviders().then((providers) => setProviders(providers));
  }, []);

  return (
    <div>
      {providers && (
        <>
          <div>
            <button onClick={
              () => signIn(providers["cognito_amazon"].id)
            }>
              Login with Amazon
            </button>
          </div>
          <div>
            <button onClick={
              () => signIn(providers["cognito_apple"].id)
            }>
              Login with Apple
            </button>
          </div>
          <div>
            <button onClick={
              () => signIn(providers["cognito_facebook"].id)
            }>
              Login with Facebook
            </button>
          </div>
          <div>
            <button onClick={
              () => signIn(providers["cognito_google"].id)
            }>
              Login with Google
            </button>
          </div>
        </>
      )}
    </div>
  );
}
```

For more context, the provider object will take the following shape:

```plaintext
{
  <prodiver_id>: {
    "id": <provider_id>
    "name": <provider_name>
    "type": <provider_type>
    "signinUrl": <provider signin url>
    "callbackUrl": <provider callback url>
  }
}
```

In this configuration, the provider object will look like this:

```json
{
  "cognito_amazon":
  {
    "id":"cognito_amazon",
    "name":"CognitoAmazon",
    "type":"oauth",
    "signinUrl":
      "http://localhost:3000/api/auth/signin/cognito_amazon",
    "callbackUrl":
      "http://localhost:3000/api/auth/callback/cognito_amazon"
  },
  "cognito_apple":{
    "id": "cognito_apple",
    "name":"CognitoApple",
    "type":"oauth",
    "signinUrl":"http://localhost:3000/api/auth/signin/cognito_apple",
    "callbackUrl":
      "http://localhost:3000/api/auth/callback/cognito_apple"
  },
  "cognito_facebook":{
    "id": "cognito_facebook",
    "name":"CognitoFacebook",
    "type":"oauth",
    "signinUrl":
      "http://localhost:3000/api/auth/signin/cognito_facebook",
    "callbackUrl":
      "http://localhost:3000/api/auth/callback/cognito_facebook"
  },
  "cognito_google":{
    "id":"cognito_google",
    "name":"CognitoGoogle",
    "type":"oauth",
    "signinUrl":
      "http://localhost:3000/api/auth/signin/cognito_google",
    "callbackUrl":
      "http://localhost:3000/api/auth/callback/cognito_google"
  }
}
```

### Frontend useSession hook

In order to access the session data in the front end, we can make use of the `useSession` hook from `next-auth/react`.

This hook allows us to access the session data and the user's auth status.

First import the hook.

```typescript
import { useSession } from 'next-auth/react'
```

Then inside your component, use it as follows:

```typescript
const { data, status } = useSession();
```

The `data` property will contain all the data that we set in the `session` callback that we configured `[...nextauth.ts]` . It will also contain an additional `user` property with information about the current user such as email address.

The `status` property is a string containing information about the user's auth status. Its possible values are "loading", "authenticated" or "unauthenticated". You can use this to implement protected routes or redirect to the sign-in page when a session is expired.