(Part 2) How to configure social authentication in a Next.js + Next-Auth + Django Rest Framework application

10 min read

NextJS + Django Rest Framework

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() and session() callbacks are called in succession.
  • Each time you use useSession() or getSession(), the jwt() and session() 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 the views.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 return 401 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() and useEffect(). 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 the setInterval() callback fired, which results in absolutely crap user experience. Hence, I had to settle for a solution with the useSwr() 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.