nuxt-auth-utils icon indicating copy to clipboard operation
nuxt-auth-utils copied to clipboard

Is session refresh implemented?

Open septatrix opened this issue 1 year ago • 23 comments

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?

septatrix avatar May 16 '24 23:05 septatrix

I believe there should be a refresh token implementation. Is this open to PR?

amandesai01 avatar May 22 '24 10:05 amandesai01

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?

atinux avatar May 22 '24 14:05 atinux

@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.

PGLongo avatar May 22 '24 20:05 PGLongo

@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?

septatrix avatar May 22 '24 21:05 septatrix

@PGLongo I would be also very interested in that! 😊

silvio-e avatar May 23 '24 05:05 silvio-e

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 avatar May 23 '24 05:05 PGLongo

@PGLongo Awesome! Very kind of you! Thank you very much!

silvio-e avatar May 23 '24 18:05 silvio-e

@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!

PGLongo avatar May 23 '24 18:05 PGLongo

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)
    }
  })
})

thijsw avatar May 29 '24 14:05 thijsw

@PGLongo do you have an answer on the question of @thijsw ? We are really looking forward to it.

doubleujay avatar Jun 14 '24 09:06 doubleujay

@doubleujay and @thijsw are you refreshing the token on server side?

PGLongo avatar Jun 15 '24 10:06 PGLongo

@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?

thijsw avatar Jun 18 '24 14:06 thijsw

@PGLongo Is the reply of @thijsw enough to give more insight?

doubleujay avatar Jun 24 '24 10:06 doubleujay

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 :)

daniandl avatar Jul 03 '24 11:07 daniandl

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

marr avatar Aug 23 '24 19:08 marr

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 avatar Sep 03 '24 08:09 sdevogel

@sdevogel are you calling await fetch() from useUserSession() in the client after you replaced the session in the server?

carlos-duran avatar Sep 03 '24 15:09 carlos-duran

@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

sdevogel avatar Sep 03 '24 16:09 sdevogel

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

cth-latest avatar Nov 07 '24 17:11 cth-latest

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

atinux avatar Nov 11 '24 16:11 atinux

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 useCookie composable.
  • Second issue: When the session is updated during SSR, any other async requests (such as a useFetch/useAsyncData/useQuery in 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,
    },
  }
})

haakonmt avatar Nov 13 '24 13:11 haakonmt

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 replace useFetch
  • $authFetch util 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.

atinux avatar Nov 14 '24 10:11 atinux