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

Session / token data disappears after restart

Open dschreij opened this issue 8 months ago • 8 comments

Environment

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


dschreij avatar Jun 11 '25 20:06 dschreij

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,
  // ...
})

phoenix-ru avatar Jun 13 '25 13:06 phoenix-ru

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.

dschreij avatar Jun 16 '25 20:06 dschreij

Thanks and please share your findings so that other could benefit from it 🙂

phoenix-ru avatar Jun 21 '25 18:06 phoenix-ru

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.

dschreij avatar Jun 22 '25 20:06 dschreij

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 avatar Jul 31 '25 23:07 muzakon

@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

phoenix-ru avatar Aug 01 '25 14:08 phoenix-ru

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.

muzakon avatar Aug 01 '25 20:08 muzakon

@dschreij I think I get what could cause it. If you refer to the NextAuth documentation:

if using NEXTAUTH_SECRET env variable, we detect it, and you won't actually need to secret

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?

phoenix-ru avatar Sep 11 '25 13:09 phoenix-ru