From 845381e84ddc6bb6eb08a0bb9cabf48aee554e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= Date: Mon, 6 Jan 2025 16:05:25 +0100 Subject: [PATCH] feat: enhance token refresh logic with request queue and improved error handling --- src/app/lib/axios.ts | 40 +++++++++++++++------ src/authOptions.ts | 84 +++++++++++++++++++++++++++----------------- 2 files changed, 80 insertions(+), 44 deletions(-) diff --git a/src/app/lib/axios.ts b/src/app/lib/axios.ts index bedf29a..a8b232f 100644 --- a/src/app/lib/axios.ts +++ b/src/app/lib/axios.ts @@ -11,24 +11,42 @@ export const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, }); +let isRefreshing = false; +let refreshQueue: Array<(token: string) => void> = []; + axiosInstance.interceptors.request.use(async (config) => { - // If the access token is still valid, use it if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) { config.headers.Authorization = `Bearer ${cachedAccessToken}`; return config; } - // Otherwise, get a new access token - const session = await getSession(); - if (!session) { - throw new Error("User is not authenticated"); + if (isRefreshing) { + // Add the request to the queue + return new Promise((resolve) => { + refreshQueue.push((token: string) => { + config.headers.Authorization = `Bearer ${token}`; + resolve(config); + }); + }); } - // Cache the new access token - cachedAccessToken = session.accessToken; - tokenExpirationAt = moment(session.accessTokenExpires); + isRefreshing = true; - // Use the new access token - config.headers.Authorization = `Bearer ${cachedAccessToken}`; - return config; + try { + const session = await getSession(); + if (!session) { + throw new Error("User is not authenticated"); + } + + cachedAccessToken = session.accessToken; + tokenExpirationAt = moment(session.accessTokenExpires); + + // Execute the queue + refreshQueue.forEach((cb) => cb(cachedAccessToken!)); + refreshQueue = []; + config.headers.Authorization = `Bearer ${cachedAccessToken}`; + return config; + } finally { + isRefreshing = false; + } }); diff --git a/src/authOptions.ts b/src/authOptions.ts index f72143c..356aa6e 100644 --- a/src/authOptions.ts +++ b/src/authOptions.ts @@ -62,7 +62,11 @@ export const authOptions: AuthOptions = { return token; } - if (moment().isBefore(moment(token.accessTokenExpires))) { + if ( + moment().isBefore( + moment(token.accessTokenExpires).subtract(5, "s"), + ) + ) { return token; } @@ -85,41 +89,55 @@ export const authOptions: AuthOptions = { }, }; -const refreshAccessToken = async (token: JWT): Promise => { - const response = await axios.post<{ - access_token: string; - expires_in: number; - refresh_token: string; - error_description?: string; - }>( - process.env.OAUTH_TOKEN_URL, - { - grant_type: "refresh_token", - refresh_token: token.refreshToken, - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, - }, - { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }, - ); +let isRefreshing = false; +let refreshPromise: Promise | null = null; - if (response.status != 200) { - throw new Error( - response.data.error_description || "Failed to refresh access token", - ); +const refreshAccessToken = async (token: JWT): Promise => { + // If another request is already refreshing the token, wait for it to finish + if (isRefreshing) { + return refreshPromise!; } - return { - ...token, - accessToken: response.data.access_token, - accessTokenExpires: moment() - .add(response.data.expires_in, "seconds") - .subtract(5, "s"), - refreshToken: response.data.refresh_token, - }; + isRefreshing = true; + refreshPromise = (async () => { + try { + const response = await axios.post<{ + access_token: string; + expires_in: number; + refresh_token: string; + error_description?: string; + }>( + process.env.OAUTH_TOKEN_URL!, + { + grant_type: "refresh_token", + refresh_token: token.refreshToken, + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + }, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + if (response.status !== 200) throw response.data; + + return { + ...token, + accessToken: response.data.access_token, + accessTokenExpires: moment() + .add(response.data.expires_in, "seconds") + .subtract(5, "s"), + refreshToken: response.data.refresh_token, + }; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; }; export const getSession = cache(() => getServerSession());