Description
Environment
- Operating System: Darwin
- Node Version: v22.14.0
- Nuxt Version: 3.17.5
- CLI Version: 3.25.1
- Nitro Version: 2.11.12
- Package Manager: pnpm@10.11.1
- Builder: -
- User Config: ssr, compatibilityDate, modules, app, future, devtools, imports, watch, runtimeConfig, plugins, auth, lodash, i18n, vuetify, piniaOrm, pinia, css, eslint, sentry, sourcemap
- Runtime Modules: @nuxt/eslint@1.3.0, @nuxt/image@1.10.0, @nuxt/scripts@0.11.5, @nuxt/test-utils@3.17.2, @nuxt/test-utils/module@3.17.2, @nuxtjs/i18n@9.4.0, @pinia/nuxt@0.10.1, @pinia-orm/nuxt@1.10.2, @sidebase/nuxt-auth@0.10.1, @vueuse/nuxt@13.0.0, @zadigetvoltaire/nuxt-gtm@0.0.13, nuxt-lodash@2.5.3, vuetify-nuxt-module@0.18.6, @formkit/auto-animate/nuxt@0.8.2, nuxt-authorization@0.3.3, @sentry/nuxt/module@9.27.0
- Build Modules: -
Reproduction
This is difficult to reproduce, as this happens quite sporadically, and cannot be provided as a repo, as the problem might be related to containerization.
- Start up the app running in a docker container / kubernetes pod
- Start a session by logging in to your app in the browser
- Do some stuff
- Restart the docker container / kubernetes pod
- Do some stuff in the app again
- Observe that calling `getToken({event}) in server side functions returns null for a while, but after waiting a few moments / a few refreshes of the browser the app starts to function as normal again, and the session information can be retrieved again...
Describe the bug
It appears as if shortly after a containerized app is restarted, the token or session information is temporarily lost. We have a server catchall endpoint that proxies calls to our api service, and before doing so, adds the token information to the header, e.g.
// /server/api/[...].ts
import { getToken } from '#auth'
import * as Sentry from '@sentry/nuxt'
const config = useRuntimeConfig()
export default defineEventHandler(async (event) => {
const apiUrl = config.apiUrl
const path = event.path.replace(/^\/api/, '')
const token = await getToken({ event })
if (!token) {
Sentry.captureException(new Error('No token found for API request'), {
extra: {
data: { token, path, method: event.method },
},
})
}
const headers = getRequestHeaders(event)
if (token?.access_token) {
headers.authorization = `Bearer ${token.access_token}`
}
await proxyRequest(event, `${apiUrl}${path}`, { headers })
})
This works great 95% of the times. However, occasionally, and shortly after a restart of the app, the getToken({event})
call returns null, while the user definitely has a session with a token still available. If you shortly wait and refresh the browser. The app starts to work as usual again and the token is found agai
I am not sure if this is is persistence problem (as it does not occur locally / non-containerized, and thus might be a thing to do with statefulness / persistence on disk?) or whether sessions are slow to start up after the application boots? So I guess my main question is where to look, as I have too little knowledge about the intrinsics of session handling by sidebase/auth
This behavior causes some users to experience 401 unauthorized errors, which we like to spare them. So it would be nice to know how to get the sessions active again after a boot up as quickly as possible :)
Additional context
Our NuxtAuthHandler code is below. We use Auth0 as our IDP. We have implemented a refresh token procedure which works great as far as we can tell:
// /server/api/auth/[...].ts
import { Buffer } from 'node:buffer'
import { NuxtAuthHandler } from '#auth'
import * as Sentry from '@sentry/nuxt'
import Auth0Provider from 'next-auth/providers/auth0'
import { isTokenValid } from '~/shared/utils/auth'
const config = useRuntimeConfig()
function convertExpiresInToExpiresAt(expiresIn: unknown): number {
if (typeof expiresIn === 'number') {
// Convert expires_in to expires_at timestamp
return Math.floor(Date.now() / 1000) + expiresIn
}
return 0
}
export default NuxtAuthHandler({
secret: config.authSecret,
providers: [
Auth0Provider.default({
clientId: config.auth0.clientId,
clientSecret: config.auth0.clientSecret,
issuer: config.auth0.domain,
authorization: {
params: {
scope: 'openid profile email offline_access',
audience: config.auth0.audience,
prompt: 'login',
},
},
}),
],
callbacks: {
async jwt({ token, account, profile }) {
token.error = undefined // Reset error on each login attempt
if (account) {
if (profile) {
Sentry.setUser({
id: profile.sub,
email: profile.email,
username: profile.name,
})
}
// First login, store the tokens in the JWT
return {
...token,
access_token: account.access_token,
id_token: account.id_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
}
}
else if (isTokenValid(token)) {
// Subsequent logins, but the `access_token` is still valid
return token
}
else {
// Subsequent logins, but the `access_token` has expired, try to refresh it
if (!token.refresh_token)
throw new TypeError('Missing refresh_token')
try {
const response = await fetch(`${config.auth0.domain}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.auth0.clientId,
client_secret: config.auth0.clientSecret,
grant_type: 'refresh_token',
refresh_token: token.refresh_token,
}),
})
if (!response.ok)
throw new Error(response.statusText)
const tokens = await response.json()
const newToken = tokens as {
access_token: string
expires_in: number
refresh_token?: string
id_token?: string
}
return {
...token,
access_token: newToken.access_token,
expires_at: convertExpiresInToExpiresAt(newToken.expires_in),
id_token: newToken.id_token ? newToken.id_token : token.id_token,
refresh_token: newToken.refresh_token,
}
}
catch (error) {
console.error('Error refreshing access_token: ', error)
// If we fail to refresh the token, return an error so we can handle it on the page
Sentry.captureException(error, {
tags: {
module: 'auth',
action: 'refresh_token',
},
})
token.error = 'RefreshTokenError'
return token
}
}
},
session({ session, token }) {
// Add Auth0 permissions to session cookie
if (token?.access_token && typeof token.access_token === 'string') {
const accessTokenData = JSON.parse(
Buffer.from(token.access_token.split('.')[1], 'base64').toString(),
)
const sessUser = session.user
if (!sessUser) {
Sentry.captureException(new Error('Session user is undefined'), {
tags: { module: 'auth' },
extra: { session },
})
return session
}
sessUser.permissions = accessTokenData.permissions
sessUser.location = accessTokenData['https://cargoplot.com/geo']
}
session.error = token.error
return session
},
},
})