Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh token error when calling getAccessToken in v4 beta #1841

Open
6 tasks done
jdwitten opened this issue Dec 13, 2024 · 14 comments
Open
6 tasks done

Refresh token error when calling getAccessToken in v4 beta #1841

jdwitten opened this issue Dec 13, 2024 · 14 comments

Comments

@jdwitten
Copy link

jdwitten commented Dec 13, 2024

Checklist

Description

Getting this error while testing out an upgrade to Next 15 + Auth0 v4. Haven't changed anything about the access/refresh token settings from v3 and need some guidance on how to debug further.

{"type":"AccessTokenError","message":"The access token has expired and there was an error while trying to refresh it. Check the server logs for more information.","stack":"AccessTokenError: The access token has expired and there was an error while trying to refresh it. Check the server logs for more information.\n    at Auth0Client.getAccessToken (/.next-dev/server/chunks/ssr/node_modules_47bd98._.js:6483:19)\n    at async getAuthorizationHeader (next-dev/server/chunks/ssr/src_82b8b1._.js:963:24)\n    at async /.next-dev/server/chunks/ssr/src_82b8b1._.js:1266:31","name":"AccessTokenError","code":"failed_to_refresh_token"},"msg":"Error retrieving access token"}

Reproduction

Here's my auth0 client initialization:

export const auth0 = new Auth0Client({
  onCallback: async (error, context) => {
    const { public: publicConfig } = getFullConfig();
    if (error != null) {
      return handleCallbackError(error.code, error.message);
    }
    return NextResponse.redirect(
      new URL(context.returnTo || '/', publicConfig.baseURL),
    );
  },
  authorizationParameters: {
    scope: 'openid profile email offline_access',
    audience: 'https://api.<my_domain>.com',
  },
});

Looks like the initial access token is valid but then after expiration I get the error above. After inspecting the session it looks like a refresh token is present, but not sure why it fails to generate an access token

Additional context

No response

nextjs-auth0 version

4.0.0-beta.10

Next.js version

15.1

Node.js version

20.9.0

@guabu
Copy link

guabu commented Dec 13, 2024

Hey @jdwitten 👋 This could happen for a few reasons, to help narrow down the issue would you mind confirming:

  1. Does your API (specified in the audience parameter) has offline access enabled via the Dashboard?
  2. Do you see any errors in the server logs? That might help indicate the specific reason why the refresh failed at the token endpoint
  3. Do you have refresh token rotation enabled? Or any specific refresh token configuration for your client?
  4. How you're calling the getAccessToken() method

Thank you!

@jdwitten
Copy link
Author

jdwitten commented Dec 13, 2024

  1. Yes the API has offline access enabled
Screenshot 2024-12-13 at 8 48 45 AM
  1. I don't see any additional errors in the Next.js server logs if that's what you mean. I checked for activity in the auth0 access logs but don't see anything their either

  2. No special configuration here. This is what my settings look like in the console

Screenshot 2024-12-13 at 8 52 44 AM
  1. We are calling getAccessToken in 2 places. Both are to populate our Authorization header that gets attached to network requests from the next.js backend server to a graphql server.
  • One is while rendering a server component - we are using the RSC Apollo client to fetch data from a gql server for rendering.
  • The other is in middleware where we are proxying some browser side graphql requests to the same gql server

Just a note that I didn't change anything about this set up from v3 -> v4. The only changes I made were to how the auth0 client was mounted via middleware vs. api routes.

Here was my v3 route set up:

export const GET = handleAuth({
  login: async (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
    const opts: LoginOptions = {
      returnTo: '/',
      authorizationParams: {
        scope: 'openid profile email offline_access',
        invitation: req.nextUrl?.searchParams?.get('invitation') ?? undefined,
        organization:
          req.nextUrl?.searchParams?.get('organization') ?? undefined,
      },
    };
    return handleLogin(req, ctx, opts);
  },
  callback: async (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
    const errorCode = req.nextUrl?.searchParams.get('error');
    if (errorCode != null) {
      return handleCallbackError(req);
    }
    return handleCallback(req, ctx);
  },
  'switch-orgs': async (req: any, ctx: AppRouteHandlerFnContext) => {
    return await handleLogin(req, ctx, {
      authorizationParams: {
        prompt: 'none',
        organization: req.nextUrl?.searchParams?.get('organization'),
      },
    });
  },
});

And here is what this turned into in v4:

export const auth0 = new Auth0Client({
  onCallback: async (error, context) => {
    const { public: publicConfig } = getFullConfig();
    if (error != null) {
      return handleCallbackError(error.code, error.message);
    }
    return NextResponse.redirect(
      new URL(context.returnTo || '/', publicConfig.baseURL),
    );
  },
  authorizationParameters: {
    scope: 'openid profile email offline_access',
    audience: 'https://<my_api>.com',
  },
});

With the middleware setup:

export const auth: MiddlewareFactory =
  (next: NextMiddleware) => async (req: NextRequest, event: NextFetchEvent) => {
    const authRes = await auth0.middleware(req);

    // authentication routes — let the middleware handle it
    if (req.nextUrl.pathname.startsWith('/auth')) {
      return authRes;
    }

    const session = await auth0.getSession();

    // user does not have a session — redirect to login
    const isPublicRoute = PUBLIC_ROUTE_PREFIXES.find(
      (path) => req.nextUrl?.pathname?.startsWith(path),
    );
    const isPublicPage = PUBLIC_PAGES.find(
      (path) => req.nextUrl?.pathname === path,
    );
    if (!isPublicRoute && !isPublicPage && !session) {
      const { origin } = new URL(req.url);
      return NextResponse.redirect(
        `${origin}/auth/login?returnTo=${buildReturnTo(req)}`,
      );
    } else {
      return next(req, event);
    }
  };

Thank you for the help, I appreciate it!

@guabu
Copy link

guabu commented Dec 14, 2024

Thanks for the information @jdwitten, this helps get a better picture!

I suspect the issue is originating from the middleware where the authRes is only being returned on the /auth routes.

To add some more context, the middleware automatically refreshes the access token if it expired and a refresh token is available. Since the authRes is not being returned for any other (non-authentication) route, if the access token expired, it will get refresh but never set via the cookies when visiting another route (say /dashboard).

You will have to ensure the headers from the authRes are applied to the final response. We have a sample on how to combine middleware here, in case that's helpful: https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#combining-middleware

Let me know if this helps!

@jdwitten
Copy link
Author

@guabu Thanks for the pointers, I tried making some adjustments to the middleware setup to accommodate the new approach, but still hitting some issues. Looking at the code for v4 I'm wondering if there is a change behavior in the getAccessToken method that I wasn't expecting. In v3 you could call getAccessToken and the sdk would attempt to refresh the access token if it was expired, but now it looks like it's just throwing an error in that case: https://github.com/auth0/nextjs-auth0/blob/v4/src/server/client.ts#L285

Maybe I'm misunderstanding, but this is essentially the behavior I'm seeing while testing my app. Everything is working fine until the access token expires and then the client starts throwing an error when I try to call getAccessToken

@jdwitten
Copy link
Author

This section of the README describes the refreshing behavior I was expecting, but that doesn't align with what I'm seeing in the code: https://github.com/auth0/nextjs-auth0/blob/v4/README.md#getting-an-access-token

@jdwitten
Copy link
Author

jdwitten commented Dec 17, 2024

After a bit more code spelunking I think I see what's going on. Please correct me if I'm misinterpreting what is happening, but this is my read on how things are working:

  1. Auth0 middleware updates the access token if necessary here https://github.com/auth0/nextjs-auth0/blob/v4/src/server/auth-client.ts#L228
  2. Then the new session data is set to the headers of the response via this function: https://github.com/auth0/nextjs-auth0/blob/v4/src/server/auth-client.ts#L238
  3. This is all fine, except I have additional application middleware that wants to use the new updated access token after calling the auth0 middleware. So to do this I invoke auth0.getAccessToken()
  4. Because this is all in the same request I get the old token via the cookies() on the incoming request here https://github.com/auth0/nextjs-auth0/blob/v4/src/server/client.ts#L266, not the headers of the response that were set in the auth0 middleware
  5. This old token is expired and as a result getAccessToken() throws the error that I'm seeing in the original message.

I've also tried calling auth0.getSession() to get the updated access token, but there's a similar problem with using the incoming request cookies https://github.com/auth0/nextjs-auth0/blob/v4/src/server/client.ts#L236 , which have the old session not the newly refreshed access token

Assuming all of this is correct, is this operating as expected? If so, I think it's important to be able to access the refreshed access token in the same middleware invocation that it was requested. Is there a supported/recommended way to do this?

@chris-erickson
Copy link

chris-erickson commented Dec 18, 2024

I'm seeing something similar in routes being called by frontend hooks. Should those api routes be included in the middleware? They were previously wrapped with withApiAuthRequired so that would make sense, but to me, not clear if middleware replaces this functionality.

The issue I previously mitigated was the user leaving the app open a long time without any page refreshes. I think I added a hook that would call a route like /me that would return some account data and redirect to login if that expired. Forgetting now all the specifics, that was quite an exhausting time of trying to figure out the proper way to deal with a web app with a very long lifetime in an open tab.

A reference example of how to conditionally include (or exclude) certain paths from authentication would be appreciated as well. It was in a way, simpler to wrap routes I wanted auth on before because it was plainly obvious if it was included or not. These concepts of a web app being logged in a while could also use some reference code or suggestions to we aren't walking into pain for no reason..

@ajwootto
Copy link

After a bit more code spelunking I think I see what's going on. Please correct me if I'm misinterpreting what is happening, but this is my read on how things are working:

  1. Auth0 middleware updates the access token if necessary here https://github.com/auth0/nextjs-auth0/blob/v4/src/server/auth-client.ts#L228
  2. Then the new session data is set to the headers of the response via this function: https://github.com/auth0/nextjs-auth0/blob/v4/src/server/auth-client.ts#L238
  3. This is all fine, except I have additional application middleware that wants to use the new updated access token after calling the auth0 middleware. So to do this I invoke auth0.getAccessToken()
  4. Because this is all in the same request I get the old token via the cookies() on the incoming request here https://github.com/auth0/nextjs-auth0/blob/v4/src/server/client.ts#L266, not the headers of the response that were set in the auth0 middleware
  5. This old token is expired and as a result getAccessToken() throws the error that I'm seeing in the original message.

I've also tried calling auth0.getSession() to get the updated access token, but there's a similar problem with using the incoming request cookies https://github.com/auth0/nextjs-auth0/blob/v4/src/server/client.ts#L236 , which have the old session not the newly refreshed access token

Assuming all of this is correct, is this operating as expected? If so, I think it's important to be able to access the refreshed access token in the same middleware invocation that it was requested. Is there a supported/recommended way to do this?

I'm seeing a similar issue in my own middleware usage when calling getAccessToken. This seems like a plausible explanation for why it's happening. Any workaround?

@guabu
Copy link

guabu commented Dec 19, 2024

Thanks for the thorough writeup @jdwitten! I've managed to reproduce the issue you're describing. In particular, this seems to happen when attempting to call getAccessToken in a middleware with an expired AT as you mentioned.

To share some context: we perform the AT refresh in the middleware to allow using the getAccessToken method in Server Components, which can't set cookies. This causes us to run into the scenario you describe. However, we'd like to continue allowing the use of getAccessToken in Server Components since there may be cases where developers would like to call an API to fetch some data before rendering a page/layout.

If so, I think it's important to be able to access the refreshed access token in the same middleware invocation that it was requested.

I agree, this is definitely something we'll work on getting fixed in the upcoming release while trying to maintain the same API we currently have. Apologies for the inconvenience here!

@guabu guabu mentioned this issue Dec 19, 2024
@ajwootto
Copy link

In a similar vein, does this mean that there are also issues with updating something in the session, and then trying to access it later in the same request?

For example, if in middleware I do something like:

const session = await auth0.getSession()
await auth0.updateSession({
  ...session,
  user: {
    ...session.user,
    newField: true
  }
})

and then somewhere else in the middleware, or in the subsequent server component render that occurs after this middleware in the same request, I try to access that field:

const session = await auth0.getSession()
console.log(session.user.newField)

what happens in this case? It seems like the update to the session isn't reflected in the context of the rest of the request, and it requires a second request before the updates are visible

@guabu
Copy link

guabu commented Dec 20, 2024

We've cut a release (4.0.0-beta.13) that returns the latest token set when calling getAccessToken().

The token, if expired, will be refreshed on the call to getAccessToken() instead of previously in the middleware. This will make the method easier to use in middleware and align better with expectations. We've added a few caveats around the method's use in Server Components as it's not possible to write cookies from them.

This should resolve the original issue reported. Please feel free to upgrade when you have a moment and let us know if you run into any issues!

In a similar vein, does this mean that there are also issues with updating something in the session, and then trying to access it later in the same request?

This is definitely something we're looking to improve. Unfortunately, we don't have a straightforward way to share context for a single request in Next.js so we are exploring some options to make it easier to read updates to the session in the same request.

@ajwootto
Copy link

With the token being refreshed in "getAccessToken", does that mean it'll have the same problem as my session case above, where calling it more than once during the same request won't work because the refreshed token isn't reflected in the session until the next request?

@ajwootto
Copy link

It does seem like on the latest version of the SDK I am still receiving this error quite frequently, could multiple calls to "getAccessToken" be the reason why?

@desjardinsalec
Copy link

desjardinsalec commented Dec 24, 2024

The token, if expired, will be refreshed on the call to getAccessToken() instead of previously in the middleware. This will make the method easier to use in middleware and align better with expectations. We've added a few caveats around the method's use in Server Components as it's not possible to write cookies from them.

@guabu What is the best way to handle that situation where a token has expired but cannot be set as a cookie? I am getting the below warning because there is not many cases where the token is used in a Client Component.

Failed to persist the updated token set. `getAccessToken()` was likely called from a Server Component which cannot set cookies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants