nuxt-auth-utils
nuxt-auth-utils copied to clipboard
Is session refresh implemented?
I saw the "offline_access" scope being used for the OAuth0 provider but no reference to refresh tokens in the codebase. Are refresh tokens implemented/utilized? Or is the session from the OAuth2 provider only used once and afterwards everything is delegated to h3?
I believe there should be a refresh token implementation. Is this open to PR?
Refresh tokens are not implemented so far as we just give back to the session what's needed and some OAuth does not handle refresh tokens.
Do you have an example of an implementation you would like to see?
@septatrix I have successfully achieved the refresh of the session with the session hook for 'fetch'. If the session has expired and I have a valid refresh token, then the refresh workflow is initiated to obtain a new valid token.
@septatrix I have successfully achieved the refresh of the session with the session hook for 'fetch'. If the session has expired and I have a valid refresh token, then the refresh workflow is initiated to obtain a new valid token.
Would you mind sharing the code for that?
@PGLongo I would be also very interested in that! 😊
Sure! Here I refresh the Microsoft Oauth. Note that in the auth handler I have stored the expirationDate in the session.user
// server/plugins/session.ts
import { useRuntimeConfig } from '#imports'
import type { OAuthMicrosoftConfig } from '~/server/api/auth/login.get'
export default defineNitroPlugin(() => {
sessionHooks.hook('fetch', async (session, event) => {
const now = new Date()
const expirationDate = new Date(session.user.expirationDate)
const jwt = getCookie(event, 'jwt')
console.log(expirationDate < now, expirationDate, now)
if (expirationDate < now || !jwt) {
const config = useRuntimeConfig(event).oauth?.microsoft as OAuthMicrosoftConfig
const tokenEndpoint = `https://login.microsoftonline.com/${config.tenant!}/oauth2/v2.0/token`
const params = new URLSearchParams()
const refreshToken = getCookie(event, 'refresh-token') || ''
params.append('client_id', config.clientId!)
params.append('client_secret', config.clientSecret!)
params.append('refresh_token', refreshToken)
params.append('grant_type', 'refresh_token')
const data = await $fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
})
const now = new Date()
session.user.expirationDate = new Date(now.getTime() + data.expires_in * 1000)
await setCookie(event, 'jwt', data.access_token, { httpOnly: true, secure: true, maxAge: data.expires_in })
}
})
sessionHooks.hook('clear', async (session, event) => {
await deleteCookie(event, 'jwt')
await deleteCookie(event, 'refresh-token')
})
})
@PGLongo Awesome! Very kind of you! Thank you very much!
@PGLongo Awesome! Very kind of you! Thank you very much!
Sharing is caring! 😊
If you have any questions or need further assistance, feel free to reach out. Happy coding!
Thank you, @PGLongo, for providing an example implementation of the refresh dynamic!
Inspired by your code, I created a similar plugin that refreshes the tokens when the access token expires. The problem I'm facing is that the sealed session cookie is never updated, so the original contents remain unchanged. After the access token expires the first time, it refreshes the tokens on every subsequent page refresh. Do you have a solution for this issue?
My code:
// server/plugins/session.ts
export default defineNitroPlugin(() => {
sessionHooks.hook('fetch', async (session, event) => {
const authenticationConfig = getAuthenticationConfig(event) // Configuration helper
const now = new Date()
const expirationDate = new Date(session.expirationDate)
if (expirationDate < now) {
// Refresh session
const body = new FormData()
body.append('grant_type', 'refresh_token')
body.append('refresh_token', session.refreshToken)
body.append('response_type', 'id_token')
body.append('client_id', authenticationConfig.clientId)
body.append('client_secret', authenticationConfig.clientSecret)
body.append('scope', authenticationConfig.scope)
const token = await $fetch<AccessToken>(authenticationConfig.tokenURL, {
method: 'post',
body
})
session.accessToken = token.access_token
session.refreshToken = token.refresh_token
session.expirationDate = new Date(now.getTime() + token.expires_in * 1000)
}
})
})
@PGLongo do you have an answer on the question of @thijsw ? We are really looking forward to it.
@doubleujay and @thijsw are you refreshing the token on server side?
@PGLongo Yes, what I'm trying to accomplish is that the tokens is refreshed server-side (when expired) and the new updated value stored in the encrypted cookie. This new, just retrieved, token should be used by all API calls within the same request cycle. However, using my previous shared code, this doesn't work. The cookie doesn't get updated, so when I refresh the page the old token is being refreshed again. Do you have a suggestion how I could address this?
@PGLongo Is the reply of @thijsw enough to give more insight?
Inspired by your code, I created a similar plugin that refreshes the tokens when the access token expires. The problem I'm facing is that the sealed session cookie is never updated, so the original contents remain unchanged. After the access token expires the first time, it refreshes the tokens on every subsequent page refresh. Do you have a solution for this issue?
I can maybe provide some insight here, dealing with this myself at the moment.
The reason the cookie may not be getting refreshed is because the set-cookie header is not forwarded on fetch calls.
This eventually lead me to a fetch wrapper snippet like this:
const res = await $fetch.raw<T>(request, {
...opts,
headers: { ...opts?.headers, ...useRequestHeaders(['cookie']) },
})
// forward cookies into SSR response
const cookies = (res.headers.get('set-cookie') || '').split(',')
for (const cookie of cookies)
appendResponseHeader(event, 'set-cookie', cookie)
// Return the data of the response
return res._data
The cookie now get set refreshed properly for me but pressing back on the browser messes this up and theres an nuxt internal error. All this trouble kind of made me rethink my token flow, I do not see a clean refresh token implementation possible on Nuxt, or SSR flows in general, where server side refreshes are needed. My conclusion here is to just ditch the refresh flow for a regular access token + auto session extension on activity + revocation flow.
Hope it helps a bit at least :)
Does the refreshCookie util help with the stale cookie issue reported by @thijsw and @daniandl? Documentation is here https://nuxt.com/docs/api/utils/refresh-cookie
Hi, currently also running into the same problem. When I replaceUserSession with new experiationDate the cookie is not updated and the session data also stays the same. Any idea how to replace the session when the refresh token is still valid? Thanks!
@sdevogel are you calling await fetch() from useUserSession() in the client after you replaced the session in the server?
@sdevogel are you calling
await fetch()from useUserSession() in the client after you replaced the session in the server?
No, how would the client know that the server replaced the session? Maybe asking for the obvious but I'm a bit lost. Thanks a lot for your help :) @carlos-duran
Can we discuss an implementation design to refresh the token here? We use this library in our application, and it’s not ideal for the session to expire while the user is using the app.
I’m willing to create a pull request after this discussion.
@atinux
Well, does this fix your issue?
https://github.com/patrick-hofmann/nuxt-auth-utils/tree/feature/add-server-side-session-storage?tab=readme-ov-file#extending-cookie-lifetime
We have found multiple issues with performing token refresh, but I believe we have found workarounds for the issues we have discovered so far. It's not pretty, but it works AFAICT.
- First issue: When a refresh is performed, the session is updated, but if performed during SSR, the session cookie is not updated on the client. We can work around this by manually updating the cookie through the
useCookiecomposable. - Second issue: When the session is updated during SSR, any other async requests (such as a
useFetch/useAsyncData/useQueryin a setup script) will not know about the updated session and will re-use the old access/refresh tokens, which we don't want. We have successfully used the event context to pass this information along to subsequent requests, but I would love to see a more ergonomic solution to this.
We use global middleware + a server route to perform refresh:
~/middleware/refresh.global.ts
import * as cookie from 'cookie-es'
export default defineNuxtRouteMiddleware(async (to) => {
const { session, loggedIn } = useUserSession()
if (
import.meta.client
|| !loggedIn.value
|| (session.value?.expiresAt ?? 0) >= Date.now()
) {
return
}
const runtimeConfig = useRuntimeConfig()
const sessionCookie = useCookie(runtimeConfig.session.name)
const event = useRequestEvent()
const headers = useRequestHeaders(['cookie'])
try {
await $fetch('/api/auth/refresh', {
// Pass cookie header during SSR, not done by default
headers,
// Doesn't make sense to retry a refresh
retry: false,
onResponse({ response }) {
// If the session cookie has been changed (which we expect after a token refresh), we pass it to subsequent requests using the event context
// In addition, we update the cookie value manually, as during SSR, this will not happen by default
let updatedSessionCookie: string | undefined
for (const setCookie of response.headers.getSetCookie()) {
const { name, value } = cookie.parseSetCookie(setCookie)
if (name === runtimeConfig.session.name) {
sessionCookie.value = value
updatedSessionCookie = cookie.serialize(name, value)
}
}
if (event) {
event.context ??= {}
event.context.updatedSessionCookie = updatedSessionCookie
}
},
})
}
catch (e) {
console.error(e)
}
})
~/server/api/refresh.get.ts
export default defineEventHandler(async (event) => {
const { expiresAt = 0 } = await requireUserSession(event)
if (expiresAt < Date.now()) {
const tokens = await fetchTokens({ grant_type: 'refresh_token' }) // Get your new tokens
await setUserSession(event, {
secure: tokens,
expiresAt: Date.now(), // Calculate from tokens.expires_in or whatever
})
}
else {
throw createError({
statusCode: 400,
message: 'invalid_refresh',
})
}
})
Then you need a custom fetcher which knows about your event context shenanigans:
~/plugins/authenticated-fetch.ts
export default defineNuxtPlugin((app) => {
const headers = useRequestHeaders(['cookie'])
const event = useRequestEvent()
const authenticatedFetch = $fetch.create({
// Ensure cookies are passed to the server route during SSR.
headers,
async onRequest({ options }) {
// Check if updated session cookie has been provided through the event context, and if so, update the headers before sending the request.
if (event?.context?.updatedSessionCookie) {
options.headers.set('cookie', event.context.updatedSessionCookie)
}
// Would probably be smart to refresh here as well if necessary. The middleware is only run during navigations, so i.e. mutations executed by user interaction can still experience expired tokens.
},
async onResponseError({ response }) {
// If we receive a 401 response, which should only happen if the refresh token has expired, redirect to the login page.
if (response.status === 401) {
await app.runWithContext(() => navigateTo('/login'))
}
},
})
return {
provide: {
authenticatedFetch,
},
}
})
Thank you for your explanation and code examples @haakonmt
I released https://github.com/atinux/nuxt-auth-utils/releases/tag/v0.5.4 that should improve the useUserSession().clear()during SSR.
I created this middleware in the playground to deal with refresh tokens that works with SSR and make sure that the useFetch and useRequestFetch() requests use the latest cookie: https://github.com/atinux/nuxt-auth-utils/blob/main/playground/middleware/jwt.global.ts
This is how it looks like to apply the updated cookie to the next request happening during the server-side rendering:
import { parse, parseSetCookie, serialize } from 'cookie-es'
await useRequestFetch()('/api/jtw/refresh', {
method: 'POST',
onResponse({ response: { headers } }) {
// Forward the Set-Cookie header to the main server event
if (import.meta.server && serverEvent) {
for (const setCookie of headers.getSetCookie()) {
appendResponseHeader(serverEvent, 'Set-Cookie', setCookie)
// Update session cookie for next fetch requests
const { name, value } = parseSetCookie(setCookie)
if (name === runtimeConfig.session.name) {
// console.log('updating headers.cookie to', value)
const cookies = parse(serverEvent.headers.get('cookie') || '')
// set or overwrite existing cookie
cookies[name] = value
// update cookie event header for future requests
serverEvent.headers.set('cookie', Object.entries(cookies).map(([name, value]) => serialize(name, value)).join('; '))
// Also apply to serverEvent.node.req.headers
if (serverEvent.node?.req?.headers) {
serverEvent.node.req.headers['cookie'] = serverEvent.headers.get('cookie') || ''
}
}
}
}
},
})
The only solution I can see for you to avoid doing this is for nuxt-auth-utils to create:
useAuthFetch()composable to replaceuseFetch$authFetchutil to replace$fetch
It will have another benefit, being able to know when an API request update the session cookie to automatically refresh the user session on the app-side.