From f85f896d237d4aacd7b16f79beeff1c5259feeff Mon Sep 17 00:00:00 2001 From: Danielle Mayabb Date: Fri, 17 Nov 2023 14:02:25 -0800 Subject: [PATCH 1/3] Implement refresh access token logic Add refresh token logic - Add function to the auth route to check the expiry of the token and attempt a refresh against the OIDC provider endpoint - Add necessary fields to next-auth types --- frontend/.env.example | 3 +- .../src/app/api/auth/[...nextauth]/route.ts | 58 ++++++++++++++++++- frontend/src/types/next-auth.d.ts | 5 ++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 37db76b54..9ab68b49e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,9 +1,10 @@ OIDC_AUTHORITY= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= +OIDC_TOKEN_ENDPOINT= API_BASE='http://phpreport-api:8555' NEXT_PUBLIC_API_BASE='http://0.0.0.0:8555' NEXTAUTH_URL=http://0.0.0.0:5173/web/v2/api/auth -NEXTAUTH_SECRET="" \ No newline at end of file +NEXTAUTH_SECRET="" diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts index de65f2361..c7d110e25 100644 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -2,6 +2,52 @@ import NextAuth, { NextAuthOptions } from 'next-auth' import KeycloakProvider from 'next-auth/providers/keycloak' import { fetchFactory } from '@/infra/lib/apiClient' import { getCurrentUser } from '@/infra/user/getCurrentUser' +import { JWT } from 'next-auth/jwt' + +/** + * Takes a token, and returns a new token with updated + * `accessToken` and `accessTokenExpires`. If an error occurs, + * returns the old token and an error property + */ +async function refreshAccessToken(token: JWT) { + try { + const url = `${process.env.OIDC_TOKEN_ENDPOINT}` + + const params = { + grant_type: 'refresh_token', + client_id: process.env.OIDC_CLIENT_ID!, + client_secret: process.env.OIDC_CLIENT_SECRET!, + refresh_token: token.refreshToken! + } + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams(params), + method: 'POST' + }) + + const refreshedTokens = await response.json() + + if (!response.ok) { + throw refreshedTokens + } + + return { + ...token, + accessToken: refreshedTokens.access_token, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + refreshToken: refreshedTokens.refresh_token ?? token.refreshToken // Fall back to old refresh token + } + } catch (error) { + return { + ...token, + error: 'RefreshAccessTokenError' + } + } +} + export const authOptions: NextAuthOptions = { providers: [ @@ -21,11 +67,15 @@ export const authOptions: NextAuthOptions = { async session({ session, token }) { session.accessToken = token.accessToken session.user = { ...session.user, ...token.user } + session.accessTokenExpires = token.accessTokenExpires + session.refreshToken = token.refreshToken return session }, async jwt({ token, account, profile }) { if (account && profile) { token.accessToken = account.access_token + token.accessTokenExpires = Date.now() + account.expires_at * 1000 + token.refreshToken = account.refresh_token token.id = profile.id const apiClient = fetchFactory({ baseURL: process.env.API_BASE!, token: token.accessToken }) @@ -34,7 +84,13 @@ export const authOptions: NextAuthOptions = { token.user = user } - return token + // Return previous token if the access token has not expired yet + if (Date.now() < token.accessTokenExpires!) { + return token + } + + // Access token has expired, try to update it + return refreshAccessToken(token) } } } diff --git a/frontend/src/types/next-auth.d.ts b/frontend/src/types/next-auth.d.ts index a5b85c9d3..fa6a43412 100644 --- a/frontend/src/types/next-auth.d.ts +++ b/frontend/src/types/next-auth.d.ts @@ -8,6 +8,8 @@ declare module 'next-auth' { interface Session { accessToken?: string user: User & DefaultSession['user'] + accessTokenExpires?: number + refreshToken?: string } /** @@ -16,6 +18,7 @@ declare module 'next-auth' { */ interface Account { access_token: string + expires_at: number } /** The OAuth profile returned from your provider */ @@ -30,5 +33,7 @@ declare module 'next-auth/jwt' { id?: string accessToken?: string user?: User + accessTokenExpires?: number + refreshToken?: string } } From 1a2ebcea39ce6816b6b4619790250f651e8e993c Mon Sep 17 00:00:00 2001 From: Leonardo Negreiros de Oliveira Date: Tue, 5 Dec 2023 18:14:47 -0300 Subject: [PATCH 2/3] Fix token refresh logic on jwt callback The jwt callback is called when the session is created or updated, this change makes the token refresh happen when the session is updated and it fixes the accessTokenExpires logic. --- .../src/app/api/auth/[...nextauth]/route.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts index c7d110e25..e5f715c54 100644 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -48,7 +48,6 @@ async function refreshAccessToken(token: JWT) { } } - export const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ @@ -69,12 +68,19 @@ export const authOptions: NextAuthOptions = { session.user = { ...session.user, ...token.user } session.accessTokenExpires = token.accessTokenExpires session.refreshToken = token.refreshToken + return session }, - async jwt({ token, account, profile }) { + async jwt({ token, account, profile, trigger }) { + if (trigger === 'update' && Date.now() > token.accessTokenExpires!) { + const newToken = await refreshAccessToken(token) + + return newToken + } + if (account && profile) { token.accessToken = account.access_token - token.accessTokenExpires = Date.now() + account.expires_at * 1000 + token.accessTokenExpires = account.expires_at * 1000 token.refreshToken = account.refresh_token token.id = profile.id @@ -84,13 +90,8 @@ export const authOptions: NextAuthOptions = { token.user = user } - // Return previous token if the access token has not expired yet - if (Date.now() < token.accessTokenExpires!) { - return token - } - // Access token has expired, try to update it - return refreshAccessToken(token) + return token } } } From 765c75648bb83bd8ebc6538cc68178eaf671bf4f Mon Sep 17 00:00:00 2001 From: Leonardo Negreiros de Oliveira Date: Tue, 5 Dec 2023 18:16:35 -0300 Subject: [PATCH 3/3] Create RefreshSession High Order Component The RefreshSession HOC adds 2 pieces of logic to the components: 1. it refreshes the session each 5 minutes, so if a token is expired it will try to refresh after 5minutes. 2. it checks if the token is expired on the window focus, and if it is expired it will try to refresh the token. This component is basically a wrapper for those funcionalities and it was made as a HOC instead of a hooks because it needed to be used within `providers.tsx` but it needed to be under `SessionProvider` since it uses the `useSession` hook. --- frontend/src/app/auth/RefreshSession.tsx | 38 ++++++++++++++++++++++++ frontend/src/app/providers.tsx | 13 ++++---- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/auth/RefreshSession.tsx diff --git a/frontend/src/app/auth/RefreshSession.tsx b/frontend/src/app/auth/RefreshSession.tsx new file mode 100644 index 000000000..c3efcc546 --- /dev/null +++ b/frontend/src/app/auth/RefreshSession.tsx @@ -0,0 +1,38 @@ +import { useSession } from 'next-auth/react' +import { useEffect } from 'react' + +const FIVE_MINUTES_IN_MILISSECONDS = 1000 * 60 * 5 + +export const RefreshSession = ({ children }: { children: React.ReactNode }) => { + // update() triggers the jwt callback on next-auth/route.ts + const { update, data } = useSession({ + required: true + }) + + // Refresh the session on a time interval + useEffect(() => { + // TIP: You can also use `navigator.onLine` and some extra event handlers + // to check if the user is online and only update the session if they are. + // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine + const interval = setInterval(() => update(), FIVE_MINUTES_IN_MILISSECONDS) + return () => clearInterval(interval) + }, [update]) + + // Refresh the session on window re-focus if the token is expired + useEffect(() => { + const visibilityHandler = () => { + if ( + document.visibilityState === 'visible' && + data?.accessTokenExpires && + Date.now() > data.accessTokenExpires + ) { + update() + } + } + + window.addEventListener('visibilitychange', visibilityHandler, false) + return () => window.removeEventListener('visibilitychange', visibilityHandler, false) + }, [data, update]) + + return children +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 1c701791e..1fc64a2f9 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -5,17 +5,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { SessionProvider } from 'next-auth/react' import { theme } from '@/ui/theme' import { AlertProvider } from '@/ui/Alert/AlertProvider' +import { RefreshSession } from './auth/RefreshSession' const queryClient = new QueryClient() export const Providers = ({ children }: { children: React.ReactNode }) => { return ( - - - {children} - - + + + + {children} + + + ) }