From b1f59249c02fa3d849bdde4a22b643f28c189e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= Date: Mon, 6 Jan 2025 21:48:31 +0100 Subject: [PATCH] feat: improve session management by handling session errors and enhancing token refresh logic --- src/app/lib/axios.ts | 11 +-- src/app/types/next-auth.d.ts | 25 +++++-- src/authOptions.ts | 125 ++++++++++++++++++----------------- 3 files changed, 91 insertions(+), 70 deletions(-) diff --git a/src/app/lib/axios.ts b/src/app/lib/axios.ts index 71c1855..263dbfb 100644 --- a/src/app/lib/axios.ts +++ b/src/app/lib/axios.ts @@ -1,8 +1,6 @@ -import { authOptions } from "@/authOptions"; import axios from "axios"; import moment, { Moment } from "moment"; -import { getSession } from "next-auth/react"; -import { redirect } from "next/navigation"; +import { getSession, signOut } from "next-auth/react"; moment.locale("fr"); @@ -36,8 +34,11 @@ axiosInstance.interceptors.request.use(async (config) => { try { const session = await getSession(); - if (!session) { - redirect(authOptions.pages!.signIn!); + if (!session || session.error) { + console.log("No session found, redirecting to login page"); + await signOut({ callbackUrl: "/auth/login" }); + // cancel the request + return new Promise(() => {}); } cachedAccessToken = session.accessToken; diff --git a/src/app/types/next-auth.d.ts b/src/app/types/next-auth.d.ts index 4d94253..f262b7a 100644 --- a/src/app/types/next-auth.d.ts +++ b/src/app/types/next-auth.d.ts @@ -25,26 +25,39 @@ declare module "next-auth" { accessToken: string; refreshToken: string; accessTokenExpires: Moment; - error?: Error; + error?: string; user: User; } interface Account { - expires_at: number; + provider: string; + type: string; + providerAccountId: string; access_token: string; + expires_at: number; + refresh_expires_in: number; refresh_token: string; + token_type: string; + id_token: string; + session_state: string; + scope: string; } } declare module "next-auth/jwt" { interface JWT { + // Default properties + name: string; + email: string; + picture: string; + sub: string; + + // Custom properties accessToken: string; accessTokenExpires: Moment; refreshToken: string; - error?: Error; + refreshTokenExpires: Moment; + error?: string; user: User | AdapterUser; - iat: number; - exp: number; - jti: string; } } diff --git a/src/authOptions.ts b/src/authOptions.ts index d4d6ae2..443b8b5 100644 --- a/src/authOptions.ts +++ b/src/authOptions.ts @@ -42,34 +42,44 @@ export const authOptions: AuthOptions = { }, ], callbacks: { - async jwt({ token, account, user }) { - if (account && user) { + async jwt({ token, account, user: profile }) { + // Initial sign in + if (account && profile) { token.accessToken = account.access_token; - token.accessTokenExpires = moment( - account.expires_at * 1000, - ).subtract(5, "s"); token.refreshToken = account.refresh_token; + token.accessTokenExpires = moment.unix(account.expires_at); + token.refreshTokenExpires = moment().add( + account.refresh_expires_in, + "seconds", + ); + const accessTokenDecode = jsonwebtoken.decode( account.access_token, ) as JWTDecoded; token.user = { - ...user, + ...profile, roles: accessTokenDecode.realm_access.roles, }; return token; } - if ( - moment().isBefore( - moment(token.accessTokenExpires).subtract(5, "s"), - ) - ) { + // Return previous token if the access token has not expired yet + if (moment().isBefore(moment(token.accessTokenExpires))) return token; - } + if ( + token.refreshTokenExpires && + moment().isAfter(token.refreshTokenExpires) + ) + return { + ...token, + error: "Refresh token has expired", + }; + + // Access token has expired, try to refresh it return refreshAccessToken(token); }, async session({ session, token }) { @@ -89,53 +99,50 @@ export const authOptions: AuthOptions = { }, }; -let isRefreshing = false; -let refreshPromise: Promise | null = null; - const refreshAccessToken = async (token: JWT): Promise => { - // If another request is already refreshing the token, wait for it to finish - if (isRefreshing) { - return refreshPromise!; + try { + const response = await axios.post<{ + access_token: string; + expires_in: number; + id_token: string; + "not-before-policy": number; + refresh_expires_in: number; + refresh_token: string; + scope: string; + session_state: string; + token_type: string; + + error?: 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, + }; + } catch { + return { + ...token, + error: "RefreshAccessTokenError", + }; } - - 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; };