Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement refresh access token logic #733

Merged
merged 3 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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=""
NEXTAUTH_SECRET=""
59 changes: 58 additions & 1 deletion frontend/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@ 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: [
Expand All @@ -21,11 +66,22 @@ 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 }) {
async jwt({ token, account, profile, trigger }) {
if (trigger === 'update' && Date.now() > token.accessTokenExpires!) {
const newToken = await refreshAccessToken(token)

dmtrek14 marked this conversation as resolved.
Show resolved Hide resolved
return newToken
}

if (account && profile) {
token.accessToken = account.access_token
token.accessTokenExpires = account.expires_at * 1000
token.refreshToken = account.refresh_token
token.id = profile.id

const apiClient = fetchFactory({ baseURL: process.env.API_BASE!, token: token.accessToken })
Expand All @@ -34,6 +90,7 @@ export const authOptions: NextAuthOptions = {

token.user = user
}

return token
}
}
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/app/auth/RefreshSession.tsx
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 8 additions & 5 deletions frontend/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SessionProvider basePath="/web/v2/api/auth">
<QueryClientProvider client={queryClient}>
<AlertProvider>
<CssVarsProvider theme={theme}>{children}</CssVarsProvider>
</AlertProvider>
</QueryClientProvider>
<RefreshSession>
<QueryClientProvider client={queryClient}>
<AlertProvider>
<CssVarsProvider theme={theme}>{children}</CssVarsProvider>
</AlertProvider>
</QueryClientProvider>
</RefreshSession>
</SessionProvider>
)
}
5 changes: 5 additions & 0 deletions frontend/src/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ declare module 'next-auth' {
interface Session {
accessToken?: string
user: User & DefaultSession['user']
accessTokenExpires?: number
refreshToken?: string
}

/**
Expand All @@ -16,6 +18,7 @@ declare module 'next-auth' {
*/
interface Account {
access_token: string
expires_at: number
}

/** The OAuth profile returned from your provider */
Expand All @@ -30,5 +33,7 @@ declare module 'next-auth/jwt' {
id?: string
accessToken?: string
user?: User
accessTokenExpires?: number
refreshToken?: string
}
}