Social Login With Cognito and NextAuth
Kelvin Mwinuka
Posted on February 24, 2023
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:
We can leverage Cognito user pools and all its rich features.
In our Next.js application, we only have to deal with 1 auth provider, Cognito.
We have the freedom to design our social sign-in buttons as we please, without being shackled by Cognito's hosted UI.
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:
A Cognito user pool.
An app client set up for your user pool.
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.
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
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);
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 }
}
}
Here's what this function is doing before returning the jwt tokens when a token request is made:
If it's the first request (i.e initial sign-in action), add
accessToken
,idToken
,refreshToken
, andexpiresAt
to the token object. TheexpiresAt
attribute lets us know when the access and id token will expire. The object returned here will be represented by thetoken
param in all subsequent token requests.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.If the tokens have not expired, return the same token object.
If the tokens have expired, fetch new tokens using the refresh token.
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.If the fetch is successful, replace the
accessToken
,idToken
andexpiresAt
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;
}
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
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>
);
}
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>
}
}
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"
}
}
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'
Then inside your component, use it as follows:
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.
Posted on February 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.