Social Login With Cognito and NextAuth

kelvinvmwinuka

Kelvin Mwinuka

Posted on February 24, 2023

Social Login With Cognito and NextAuth

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.

  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:

Setting up NextAuth

In the Next.js application, first, install the NextAuth library.

npm install next-auth
Enter fullscreen mode Exit fullscreen mode

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

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);
Enter fullscreen mode Exit fullscreen mode

As you can see, we're configuring a basic OAuth2.0 flow. You can read more about Cognito's OIDC endpoints here. 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:

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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;
}
Enter fullscreen mode Exit fullscreen mode

Configuring shared session state

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

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
Enter fullscreen mode Exit fullscreen mode

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.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

{
  <prodiver_id>: {
    "id": <provider_id>
    "name": <provider_name>
    "type": <provider_type>
    "signinUrl": <provider signin url>
    "callbackUrl": <provider callback url>
  }
}
Enter fullscreen mode Exit fullscreen mode

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

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

import { useSession } from 'next-auth/react'
Enter fullscreen mode Exit fullscreen mode

Then inside your component, use it as follows:

const { data, status } = useSession();
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
kelvinvmwinuka
Kelvin Mwinuka

Posted on February 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Social Login With Cognito and NextAuth
cognito Social Login With Cognito and NextAuth

February 24, 2023