(Part 2) How to configure social authentication in a Next.js + Next-Auth + Django Rest Framework application
10 min read
Disclaimer: The pitfalls of the
useSession()
hook of the next-auth package mentioned in this article is valid at the time of writing, but may not be the case depending on when you are reading this. The solution to the pitfall is currently being actively worked on.
In my previous article in this series, I talked about the basic template of configuring a Next.js + NextAuth app with Django Rest Framework. It works, but it has some flaws:
- No way to use the refresh token
useSession()
happens to have its own set of pitfalls (more on that later)
Even though dj-rest-auth
package gives us endpoints to obtain rotating access and refresh tokens, we are not making use of those endpoints. Let’s fix that.
Refreshing access token
In order to refresh the access token, we need to understand how the Next-Auth flow works. You can find the details in the Next-Auth documentation on callbacks, but basically:
- User logs in. The code in
[...nextauth].ts
is going to run. - The first callback to get immediately called after sign in is the
signIn()
callback. - After that, the
jwt()
andsession()
callbacks are called in succession. - Each time you use
useSession()
orgetSession()
, thejwt()
andsession()
callbacks are run again.
As such, the refreshing mechanism needs to be done in the jwt()
callback, and any modifications to be done to the session object is done in the session()
callback. So, let’s do some modifications to the pages/api/auth/[...nextauth].ts
file. Before that, create a new file client/constants/Utils.ts
:
import jwt from 'jsonwebtoken';
export namespace JwtUtils {
export const isJwtExpired = (token: string) => {
// offset by 60 seconds, so we will check if the token is "almost expired".
const currentTime = Math.round(Date.now() / 1000 + 60);
const decoded = jwt.decode(token);
console.log(`Current time + 60 seconds: ${new Date(currentTime * 1000)}`);
console.log(`Token lifetime: ${new Date(decoded['exp'] * 1000)}`);
if (decoded['exp']) {
const adjustedExpiry = decoded['exp'];
if (adjustedExpiry < currentTime) {
console.log('Token expired');
return true;
}
console.log('Token has not expired yet');
return false;
}
console.log('Token["exp"] does not exist');
return true;
};
}
export namespace UrlUtils {
export const makeUrl = (...endpoints: string[]) => {
let url = endpoints.reduce((prevUrl, currentPath) => {
if (prevUrl.length === 0) {
return prevUrl + currentPath;
}
return prevUrl.endsWith('/') ? prevUrl + currentPath + '/' : prevUrl + '/' + currentPath + '/';
}, '');
return url;
};
}
This file contains two functions: one to check for JWT token expiration, and the other for creating API endpoints. We depend on the jsonwebtoken
package, which you can install by running yarn add jsonwebtoken
and yarn add -D @types/jsonwebtoken
if you need the type definitions. We will be using these two functions in the client/pages/api/auth/[...nextauth].ts
file:
import { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { NextAuthOptions } from 'next-auth';
import Providers from 'next-auth/providers';
import axios from 'axios';
import { JwtUtils, UrlUtils } from '../../../constants/Utils';
namespace NextAuthUtils {
export const refreshToken = async function (refreshToken) {
try {
const response = await axios.post(
// "http://localhost:8000/api/auth/token/refresh/",
UrlUtils.makeUrl(process.env.BACKEND_API_BASE, 'auth', 'token', 'refresh'),
{
refresh: refreshToken
}
);
const { access, refresh } = response.data;
// still within this block, return true
return [access, refresh];
} catch {
return [null, null];
}
};
}
const settings: NextAuthOptions = {
secret: process.env.SESSION_SECRET,
session: {
jwt: true,
maxAge: 24 * 60 * 60 // 24 hours
},
jwt: {
secret: process.env.JWT_SECRET
},
debug: process.env.NODE_ENV === 'development',
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
})
],
callbacks: {
async jwt(token, user, account, profile, isNewUser) {
// user just signed in
if (user) {
// may have to switch it up a bit for other providers
if (account.provider === 'google') {
// extract these two tokens
const { accessToken, idToken } = account;
// make a POST request to the DRF backend
try {
const response = await axios.post(
// tip: use a seperate .ts file or json file to store such URL endpoints
// "http://127.0.0.1:8000/api/social/login/google/",
UrlUtils.makeUrl(process.env.BACKEND_API_BASE, 'social', 'login', account.provider),
{
access_token: accessToken, // note the differences in key and value variable names
id_token: idToken
}
);
// extract the returned token from the DRF backend and add it to the `user` object
const { access_token, refresh_token } = response.data;
// reform the `token` object from the access token we appended to the `user` object
token = {
...token,
accessToken: access_token,
refreshToken: refresh_token
};
return token;
} catch (error) {
return null;
}
}
}
// user was signed in previously, we want to check if the token needs refreshing
// token has been invalidated, try refreshing it
if (JwtUtils.isJwtExpired(token.accessToken as string)) {
const [newAccessToken, newRefreshToken] = await NextAuthUtils.refreshToken(token.refreshToken);
if (newAccessToken && newRefreshToken) {
token = {
...token,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000 + 2 * 60 * 60)
};
return token;
}
// unable to refresh tokens from DRF backend, invalidate the token
return {
...token,
exp: 0
};
}
// token valid
return token;
},
async session(session, userOrToken) {
session.accessToken = userOrToken.accessToken;
return session;
}
}
};
export default (req: NextApiRequest, res: NextApiResponse) => NextAuth(req, res, settings);
If you are here from Part 1 of my article, you would notice that the signIn()
function is missing, and the associated logic is now inside jwt()
callback. This was done after consultation with one of the maintainers of Next-Auth. You can read the conversation here, but basically, monkey-patching the user
object the way I was doing it before is not actually needed. The rest of the code is self-explanatory: we check the expiration time of the access token, if it is nearly expired (refer to the Utils.ts
file, we are offsetting current time by 60 seconds), then try to refresh it by communicating with the DRF backend. Also notice that we have refactored the backend base API endpoint into an environment variable.
Right now, with the current modifications in the [...nextauth].ts
file, you have the ability to refresh the access tokens from the DRF backend, as long as the refresh token itself is valid.
All looks good, right? Well… not quite.
Pitfalls of the useSession()
hook
Remember that in our client/pages/index.tsx
file, we have the following code with the useSession()
hook:
import { signIn, signOut, useSession } from "next-auth/client";
export default function Home() {
const [session, loading] = useSession();
return (
<>
{
loading && <h2>Loading...</h2>
}
{!loading && !session && (
<>
Not signed in <br />
<button onClick={() => signIn()}>Sign in</button>
<pre>{!session && "User is not logged in"}</pre>
</>
)}
{!loading && session && (
<>
Signed in as {session.user.email} <br />
<button onClick={() => signOut()}>Sign out</button>
{
session.accessToken && (
<pre>User has access token</pre>
)
}
</>
)}
</>
);
}
It turns out that currently there is an issue with the useSession()
hook as well as the getSession()
function, which prevents them from automatically getting the updated session
object after the access token is refreshed. In other words, unless you do a hard page refresh, you won’t get the updated access token in your client and your subsequent API requests to the DRF backend that require a valid token will fail.
If you are looking for an example, I actually have one set up in the associated Github repository. For the brevity of this article, I am not going to write out everything, but the gist of it is:
-
There is a
posts
app in the DRF backend. The only view in theviews.py
file pings JSONPlaceholder and returns the posts it can find. This route requires authentication, e.g. the access token. -
At the frontend, I am polling the above API endpoint at regular intervals of 10 seconds. Once the access token expires, even though it is being refreshed in the
jwt()
callback, the client still has the old access token. Therefore, the subsequent calls to the API will return401 Unauthorized
.
So, then, what’s the solution?
Custom useAuth()
hook with useSwr()
To solve/work around the above problem, an approach that I came up with following this comment is to use the useSwr()
hook in the swr
package. Install the package first by running yarn add swr
. I created a custom hook that will ping the session REST API provided by Next-Auth at regular intervals to keep the session
object updated.
const sessionUrl = '/api/auth/session';
async function fetchSession(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not fetch session from ${url}`);
}
const session: Session = await response.json();
if (!session || Object.keys(session).length === 0) {
return null;
}
return session;
}
// ### useSwr() approach works for now ###
export function useAuth(refreshInterval?: number) {
/*
custom hook that keeps the session up-to-date by refreshing it
@param {number} refreshInterval: The refresh/polling interval in seconds. default is 20.
@return {tuple} A tuple of the Session and boolean
*/
const { data, error } = useSwr(sessionUrl, fetchSession, {
revalidateOnFocus: true,
revalidateOnMount: true,
revalidateOnReconnect: true
});
useEffect(() => {
const intervalId = setInterval(() => mutate(sessionUrl), (refreshInterval || 20) * 1000);
return () => clearInterval(intervalId);
}, []);
return {
session: data,
loading: typeof data === 'undefined' && typeof error === 'undefined'
};
}
Side note about the approach above: I was actually reluctant to use this approach and instead wanted to get it done using built-in React hooks, namely
useState()
anduseEffect()
. However, I failed to do so, as you will see in this file in the Git repository. For those of you who are interested to know, the issue with using built-in hooks was that the page would arbitrarily refresh itself after thesetInterval()
callback fired, which results in absolutely crap user experience. Hence, I had to settle for a solution with theuseSwr()
hook. If any of you are able to solve the issue using built-in hooks, feel free to make a pull request in the Git repository.
So, now you can replace all calls to useSession() with useAuth() .
- const [session, loading] = useSession();
+ const { session, loading } = useAuth(); // default 20 seconds refresh interval for session
// OR, if you want to have custom refresh interval
+ const { session, loading } = useAuth(3 * 60); // 3 minutes refresh interval for session
Higher Order Component to get rid of code repetition (optional)
The last piece of the puzzle came in the form of a HOC, since I did not want to write the following lines in every single page of the app:
if (typeof window !== 'undefined' && loading) {
return null;
}
if (!loading && !session) {
return <AccessDenied />
}
return <MyComponent />
As such, I created a withAuth
HOC.
import { Session } from "next-auth";
import { signIn } from "next-auth/client";
import React from "react";
import { useAuth } from "./Hooks";
type TSessionProps = {
session: Session;
};
export function withAuth<P extends object>(refreshInterval?: number) {
/*
@param { number } refreshInterval: number of seconds before each refresh
*/
return function (Component: React.ComponentType<P>) {
return function (props: Exclude<P, TSessionProps>) {
const { session, loading } = useAuth(refreshInterval);
if (typeof window !== undefined && loading) {
return null;
}
if (!loading && !session) {
return (
<>
Not signed in <br />
<button onClick={() => signIn()}>Sign in</button>
<pre>{"User is not logged in"}</pre>
</>
);
}
return <Component session={session} {...props} />;
};
};
}
Now, you can export your components by wrapping it with this HOC, and it will take care of handling the session object and showing an Access Denied component if the user is not logged in.
// default session refresh interval of 20 seconds
export default withAuth()(Posts);
// session refresh interval of 3 minutes
export default withAuth(3 * 60)(Posts);
Conclusion
This took way longer than I anticipated, but I learnt a lot in the process. I hope this series of articles was able to help you out in some ways to connect your Django backend with a Next.js + Next-Auth app. In hindsight, the same principles should apply for connecting basically any custom backend with Next-Auth and Next.js.
Feel free to leave your comments/criticisms/suggestions. The associated Github repository for all the code can be found here.