Refresh Token rotation in Next.js using Auth.js

Refresh Token rotation in Next.js using Auth.js

Introduction

In React applications, user authentication can be quite daunting to implement, as it doesn't come with all the features required of a single-page application, well it's a library, not a framework. It comes with a limited set of features, however, it has a huge community of developers, there's probably a library for whatever features you might wish to implement, if there aren't you might have to create one for us 😁. Or you can start strong by using a solid framework like Next.js, Remix, Gatsby etc. Next.js is a React framework that has many features out of the box.

Auth.js

Auth.js is an authentication library for Next.js that uses JWT by default, with built-in support for popular services like Google, Facebook, Auth0, and Apple etc, with even support for regular email and password authentication. There's currently in-development support for SvelteKit and SolidStart. It's fast, secure and easy to deploy.

Not long ago I wrote an article "Refresh Token rotation in NestJS JWT authentication" where we talked about access and refresh token and JWT authentication, and I thought of how we can have it implemented in any front-end application, you can check out the article.

One of the reasons I set out to write this article was issues with refresh token rotation. This issue mostly occurs in SPAs(Single Page Applications) when a refresh token has been previously used and the app is trying to use this already invalidated token thereby resulting in an authentication error due to an invalidated refresh token. This can be caused by network latency or when two different tabs of the browser send requests at the same time. So basically there is a race condition here, where two calls made to the "/api/auth/session" endpoint will both try to refresh the access token, whichever one succeeds first would have used the refresh token and makes it invalidated and causes the latter to fail.

At the moment of writing this article, there isn't a standard way of implementing refresh token rotation in Auth.js. There's currently an open discussion on how this could be implemented but isn't something to consider in many cases. There is also a tutorial on the docs which could be sufficient enough for your use case.

Possible ways to fix this issue can be to provide some sort of lock mechanism, which allow scripts running in one tab to asynchronously acquire a lock, hold it while work is performed, then release it. While held, no other script in another tab executing the same origin can acquire that lock, which allows the app running in multiple tabs to coordinate and use the resources. However, this Web API is currently experimental. Another way this can be solved is by saving the refresh token into an external source, like a database or a cache. Redis is a perfect fit for this. Interestingly Redis also has distributed lock pattern.

Setup

Using docker we can set up a Redis container which is used to store refresh tokens. Without letting this article become too long, a backend is already in place which returns both access and refresh tokens upon signing in. For a head start you can check this article "Refresh Token rotation in NestJS JWT authentication".

Authentication Flow

When a user signs in using his/her credentials, upon successful verification, the backend returns accessToken, refreshToken , tokenId , accessTokenExpires and user To add Auth.js to a project, create a file called [...nextauth].ts in pages/api/auth. This contains the dynamic route handler for Auth.js which will also contain all of your global Auth.js configurations and any other intended logic. In the provider's array of the authOptions is CredentialsProvider which allows you to handle signing in with arbitrary credentials, such as an email and password.

pages/api/auth/[…nextauth].ts

providers: [
    CredentialsProvider({
      name: 'credentials',
      async authorize(credentials: any, req): Promise<any | null> {
        let user = {}
        try {
          const response = await axios.post(
            '/auth/sign-in',
            {
              email: credentials?.email,
              password: credentials?.password,
            },
          )

          if (response.status == 201) {
            user = response.data
            return user
          }
        } catch (e: any) {
          console.log(e.response.data)
        }
        return null
      },
      credentials: {},
    })
  ],

The authorize method takes the credentials submitted and sends a post request to our backend '/auth/sign-in' API and returns an object containing accessToken, refreshToken , tokenId , accessTokenExpires and user which is then passed to the jwt callback.

With Redis distributed lock pattern, when multiple requests are made, and the first had already refreshed and updated the tokens, the subsequent request will have access to the updated token, we can then return the updated token without making another attempt to refresh the token.

pages/api/auth/[…nextauth].ts

/*
.
*/
import Client from 'ioredis'
import Redlock from 'redlock'

const redis = new Client(6379, 'localhost', {})

const redlock = new Redlock([redis], {
  driftFactor: 0.01,
  retryCount: 10,
  retryDelay: 200,
  retryJitter: 200,
  automaticExtensionThreshold: 500,
})
/*
.
*/

Auth.js hooks into the authentication flow using the built-in callbacks. the jwt callback is called whenever a JSON Web Token is created (i.e. at sign-in) or updated (i.e whenever a session is accessed in the client). This callback is where we decide if the token is ready to be refreshed. The session callback is where we specify what will be available to the client with useSession() hook or getSession() on the server.

pages/api/auth/[…nextauth].ts

 callbacks: {
    async jwt({ token, user, account, profile }) {
      if (account && user) { 
        //olny run at first sign on.
        let data: any = {}

        token = {
          refreshToken: data?.refreshToken,
          accessToken: data?.accessToken,
          tokenId: data?.tokenId,
          accessTokenExpires: data?.accessTokenExpires,
          user: data?.user,
        }

        await redis.set(`token:${token.tokenId}`,JSON.stringify(token))
        return token
      }

      // Return previous token if the access token has not expired yet
      const expires = dayjs(token?.accessTokenExpires)
      const diff = expires.diff(dayjs(), 'second')
      if (diff > 0) {
        return token
      }

      // Access token has expired, try to do a refresh
      return await redlock.using(
        [token?.user.userId, 'jwt-refresh'],
        5000,
        async () => {
          // Always get the refresh_token from redis
          const redisToken = await redis.get(`token:${token.tokenId}`)
          const currentToken = JSON.parse(redisToken ?? '')

          // This can happen when the there are multiple requests
          // and the first request already refreshed the tokens
          // so the consecutive requests already have access to the updated tokens
          const expires = dayjs(token.accessTokenExpires)
          const diff = expires.diff(dayjs(), 'second')
          if (diff > 0) {
            return currentToken
          }

          // If it's the first request to refresh the tokens then
          // get your new tokens here, something like this:
          const newTokens = await refreshAccessToken(currentToken)

          // Save new jwt token object to redis for the next requests
          await redis.set(
            `token:${newTokens.tokenId}`,
            JSON.stringify(newTokens),
          )

          // Return new jwt token
          return newTokens
        },
      )
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken
      session.accessTokenExpires = token.accessTokenExpires
      session.user = token.user

      session.error = token.error
      return session
    }
  },

On the first sign-on, we store the refresh token in Redis with key `token:${token.tokenId}` and when next useSession() hook or getSession() is called, the jwt callback is fired and checks if the token is expired or not, if not it returns the token. In case we have an expired access token, we then use Redlock to try and get the refresh token by sending a request to the '/auth/refresh' endpoint of our backend.

pages/api/auth/[…nextauth].ts

async function refreshAccessToken(tokenObject: any) {
  try {
    const tokenResponse = await axios.get('/auth/refresh', {
      headers: {
        Authorization: `Bearer ${tokenObject.refreshToken}`,
        'Token-Id': tokenObject.tokenId,
      },
    })

    return {
      ...tokenObject,
      accessToken: tokenResponse.data.accessToken,
      refreshToken: tokenResponse.data.refreshToken,
      accessTokenExpires: tokenResponse.data.accessTokenExpires,
    }
  } catch (error: any) {
    console.log('refresh error', error.response.data)
    return {
      ...tokenObject,
      error: 'RefreshAccessTokenError',
    }
  }
}

Client Side

To be able to use useSession() hook, we'll need to expose the session context, <SessionProvider>, at the top level of our application.

import { AppProps } from 'next/app'

import { ThemeProvider } from 'next-themes'
import { SessionProvider } from 'next-auth/react'

export default function App({
  Component,
  pageProps: { session, ...pageProps },
}: AppProps) {
  return (
    <ThemeProvider>
      <SessionProvider session={session}>
        <div className="flex flex-col max-w-7xl mx-auto w-full items-center ">
          <Component {...pageProps} />
        </div>
      </SessionProvider>
    </ThemeProvider>
  )
}

It's pretty easy to set up a simple sign-in and register form to test how both the signIn() and signOut() method works.

pages/auth/sign-in.ts

/*
***
*/
const SignIn = () => {
  const router = useRouter()

  const [formValues, setFormValues] = useReducer(
    (prev: IFormValues, next: IFormValues) => {
      return { ...prev, ...next }
    },
    initiialFormValues,
  )

  const handleSubmit = async (e: any) => {
    e.preventDefault()
    const response: any = await signIn('credentials', {
      ...formValues,
      callbackUrl: `${window.location.origin}/`,
      redirect: false,
    })
    if (response?.ok) {
      router.push('/')
    }
    if (!response?.ok) {
      alert(response)
      console.log('err', response)
    }
  }

  return (
    <>
      <div className="flex flex-col w-full pb-5 justify-center">
        <form
          onSubmit={handleSubmit}
          className="bg-gray-100 p-5 rounded-lg shadow-lg w-96 mx-auto"
        >
          <h1 className="text-center text-2xl mb-6 text-gray-600 font-bold font-sans">
            Login
          </h1>

          <div>
            <label className="text-gray-800 font-semibold block my-3">
              Email
            </label>
            <input
              onChange={(e: any) =>
                setFormValues({
                  email: e.target.value,
                })
              }
              value={formValues.email}
              className="w-full bg-white px-4 py-2 rounded-lg focus:outline-none"
              type="text"
              name="email"
              id="email"
              placeholder="@email"
            />
          </div>
          <div>
            <label className="text-gray-800 font-semibold block my-3">
              Password
            </label>
            <input
              onChange={(e: any) => setFormValues({ password: e.target.value })}
              value={formValues.password}
              className="w-full bg-white px-4 py-2 rounded-lg focus:outline-none"
              type="password"
              name="password"
              id="password"
              placeholder="password"
            />
          </div>

          <button
            type="submit"
            className="w-full mt-6 mb-3 bg-indigo-100 rounded-lg px-4 py-2 text-lg text-gray-800 tracking-wide font-semibold font-sans"
          >
            Login
          </button>

          <div className="inline-flex space-x-2">
            <h2 className="text-xs">Don't have an account?</h2>
            <Link href="/auth/register">
              <h2 className="text-blue-500 text-xs">Register</h2>
            </Link>
          </div>
        </form>
      </div>
    </>
  )
}
/*
***
*/
export default SignIn

To protect pages Auth.js uses middleware. It is a way to run logic before accessing any page, even when they are static. we add a middleware.ts to the root of the source code. If you only want to secure certain pages, export a config object with a matcher

middleware.ts

import { withAuth } from 'next-auth/middleware'

// More on how NextAuth.js middleware works: https://next-auth.js.org/configuration/nextjs#middleware
export default withAuth({
  callbacks: {
    authorized({ req, token }) {
      return !!token
    },
  },
})

export const config = { matcher: ['/'] }

Now only the index page will require authentication while others won't.

Conclusion

In our backend, we had set up leeway for any invalidated refresh token to remain valid for at least a minute but definitely could still result in an error. This error would not exist when a user uses a single tab of our app in a browser, or when the backend is consumed in a native app. But we can never be sure how our users intend to use our app, as it could result in a bad user experience if they keep having to sign back in anytime authentication fails to persist. There might exist a better way to solve this in the future or if you've got a better solution, please feel free to leave a comment below or you can hit me up on Twitter. Till next time, see ya! 👋🏽

Source code:

Frontend Backend