Session / token data disappears after restart
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: [email protected]
- 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/[email protected], @nuxt/[email protected], @nuxt/[email protected], @nuxt/[email protected], @nuxt/test-utils/[email protected], @nuxtjs/[email protected], @pinia/[email protected], @pinia-orm/[email protected], @sidebase/[email protected], @vueuse/[email protected], @zadigetvoltaire/[email protected], [email protected], [email protected], @formkit/auto-animate/[email protected], [email protected], @sentry/nuxt/[email protected]
- 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
},
},
})
Logs
Hi, we've never experienced sessions being lost across app restarts. But we use a database adapter: https://next-auth.js.org/configuration/databases
My guess is that user sessions managed by next-auth are in-memory, but it's better you refer to their source code:
https://github.com/nextauthjs/next-auth/tree/v4/packages/next-auth
You can also enable debug mode by adding:
export default NuxtAuthHandler({
debug: true,
// ...
})
Thanks @phoenix-ru. If the sessions are persisted in-memory, than a restart of the local service would also wipe them if I am correct? And that doesn't happen. Moreover, even in the Kubernetes environment, the problem doesn't always occur on every restart, but only intermittently, making it really hard to debug. I also thought all required session info was just stored in the session Cookie, but I might be wrong. If you say next-auth is the place to look at, then I'll do some more digging there.
I have checked the database configuration page you linked, but that is not only for session storage and rather for supporting the whole local provider installation, while we use an IDP. Maybe worth a try if we don't find any other way of fixing this problem.
One of my goals of placing this issue is to find out if other users are also experiencing this problem, but thusfar I get the impression we're the exception.
Thanks and please share your findings so that other could benefit from it 🙂
Hi @phoenix-ru, no luck just yet. We just cannot seem to get to the bottom of this issue and have trouble understanding sidebase/auth or next-auth session mechanism. To our understanding there is no layer of persistence, and all session information is stored in a cookie stored in the browser. Yet if I clear all my browsers cookie, I am still authenticated when accessing the app, so there must be some persistence somewhere else, such as localStorage.
I had the assumption that sidebase/auth used h3 sessions, but judging from your last comment all session management is done by next-auth (?). The next-auth documentation on how their sessions are maintained is also difficult to find, so I have to keep searching.
Hello, the similar issue is here. I use same version of Sidebase Auth.
I have not tested in containerized app, but the same issue occurs when i turn off my laptop's screen. After couple minutes, when i turn on and refresh the page, it redirects me to the login page and my session is totally removed from cookies.
Although the session token's age expires 3 days later (it is not marked as a session cookie), its removed. By the way, nothing happens when i close browser etc. Everything works fine. It only happens after turning off the screen. It might be about Nuxt server working behind, i have no idea.
My [...].ts:
import type { SignInUserRequestForm } from '~/interfaces/auth.interface'
import { readFileSync } from 'node:fs'
import { NuxtAuthHandler } from '#auth'
import jwt from 'jsonwebtoken'
import ky, { HTTPError } from 'ky'
// Providers
import CredentialsProvider from 'next-auth/providers/credentials'
import GoogleProvider from 'next-auth/providers/google'
export default NuxtAuthHandler({
secret: process.env.NUXT_AUTH_SECRET,
pages: {
signIn: '/auth/login',
signOut: '/auth/logout',
error: '/auth/login',
},
providers: [
GoogleProvider.default({
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
}),
CredentialsProvider.default({
async authorize(credentials: SignInUserRequestForm) {
if (credentials.error) {
throw new Error(credentials.error)
}
try {
return await ky.post('http://localhost:8000/auth/login', {
json: {
email: credentials.email,
password: credentials.password,
captchaToken: credentials.captchaToken,
},
}).json()
}
catch (error) {
if (error instanceof HTTPError) {
const errorResponse = await error.response.json()
throw new Error(errorResponse.message)
}
else {
console.error('Unexpected Error:', error)
}
}
return null
},
}),
],
session: {
strategy: 'jwt',
maxAge: 60 * 60 * 24 * 3,
},
cookies: {
sessionToken: {
name: 'next-auth.session-token',
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
},
},
},
})
My Nuxt config:
auth: {
globalAppMiddleware: {
isEnabled: false,
},
isEnabled: true,
originEnvKey: 'AUTH_ORIGIN',
provider: {
type: 'authjs',
defaultProvider: 'credentials',
addDefaultCallbackUrl: false,
},
sessionRefresh: {
enablePeriodically: 60000,
},
},
By the way, the app runs without SSR. So, SSR is disabled.
@muzakon If it helps, data is "persisted" using useState:
https://github.com/sidebase/nuxt-auth/blob/45fd69c5c063560669ebc943bf6340a3d17266a3/src/runtime/composables/commonAuthState.ts#L6
I haven't checked how it behaves in no-SSR environments, however
Hello, thank you for your response. I've identified the problem in my app.
In my global auth middleware, I've been calling my external api to set user profile by using Ky HTTP package.
In this Ky composable;
import type { KyInstance } from "ky";
import ky from "ky";
export default function useApi(prefix: string | null = null): KyInstance {
const config = useRuntimeConfig();
const { data, signOut, status, signIn } = useAuth();
const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (status.value === "authenticated" && data.value?.accessToken) {
headers.Authorization = `Bearer ${data.value.accessToken}`;
}
let apiUrl = config.public.backendApiUrl;
if (prefix) {
apiUrl = `${apiUrl}/${prefix}`;
}
const api: KyInstance = ky.create({
prefixUrl: apiUrl,
headers,
retry: {
limit: 3,
methods: ["get", "post", "put", "delete"],
statusCodes: [500, 502, 503, 504],
},
hooks: {
afterResponse: [
async (request, options, response) => {
if (response.status === 401) {
if (status.value === "authenticated") {
return signOut({
redirect: true,
callbackUrl: "/auth/login",
});
} else {
return signIn(undefined, {
callbackUrl: "/",
error: "SessionRequired",
})
.then(() => undefined)
.catch(() => false);
}
}
},
],
},
});
return api;
}
As you see I force user to sign out after getting 401 from my external API.
However, the problem is, when I call this composable in global auth middleware, the accessToken is unreachable. (data.value.accessToken) So, no matter what, i was getting 401 on fresh PC start.
Thats still weird to me a little bit because when i close my browser, app etc. it never signs me out, the access token is reachable. It only happens on PC start.
So I created a plugin that runs after every route such as:
import { UsersApiService } from "~/services/api/users.service";
import { useAuthStore } from "~/store/auth.store";
export default defineNuxtPlugin((nuxt) => {
useRouter().afterEach(async (to, from) => {
const usersApiService = new UsersApiService();
const authStore = useAuthStore();
const { status } = useAuth();
if (status.value === "authenticated") {
if (!authStore.profile) {
await usersApiService.setUserProfile();
}
if (authStore.profile?.is_completed && to.path === "/complete-account") {
return navigateTo("/");
}
// If the user's profile is NOT complete and they are NOT already on the /complete-account page,
// redirect them to the complete-account page. This prevents the redirect loop.
if (!authStore.profile?.is_completed && to.path !== "/complete-account") {
return navigateTo("/complete-account");
}
}
});
});
Now it seems like fixed.
@dschreij I think I get what could cause it. If you refer to the NextAuth documentation:
if using
NEXTAUTH_SECRETenv variable, we detect it, and you won't actually need tosecret
But NuxtAuth infers the secret from the value you provide it:
https://github.com/sidebase/nuxt-auth/blob/0088492b64e30c7efd5981fa0533ec2cc6edc94c/src/runtime/server/services/authjs/nuxtAuthHandler.ts#L24
https://github.com/sidebase/nuxt-auth/blob/0088492b64e30c7efd5981fa0533ec2cc6edc94c/src/runtime/server/services/authjs/nuxtAuthHandler.ts#L32
And then passes it to getToken:
https://github.com/sidebase/nuxt-auth/blob/0088492b64e30c7efd5981fa0533ec2cc6edc94c/src/runtime/server/services/authjs/nuxtAuthHandler.ts#L175
It might be the case that Nuxt lazily initializes your authentication route and at the time something calls getToken the secret is not known to the module yet.
Can you please try manually specifying the secret when you do getToken? For instance,
const token = await getToken({ event, secret: useRuntimeConfig().authSecret })
Would this solve the problem for you?