Table of contents
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: