getTokenSilently fails on tab lock logic

Hi, after implementing rotating tokens yesterday and working the entire day on my dev environment, today a problem started showing up - in several occasions getTokenSilently() fails to acquire a lock.

I am not using incognito nor have I localStorage/cookies disabled.

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'default')
    at e.<anonymous> (auth0-spa-js.production.esm.js:1:1)
    at auth0-spa-js.production.esm.js:1:1
    at Object.next (auth0-spa-js.production.esm.js:1:1)
    at auth0-spa-js.production.esm.js:1:1
    at new Promise (<anonymous>)
    at o (auth0-spa-js.production.esm.js:1:1)
    at auth0-spa-js.production.esm.js:1:1

My setup:

<Auth0Provider
      domain={'...'}
      clientId={'...'}
      authorizationParams={{
        redirect_uri: authRedirectUrl,
        scope: '...',
        audience: '...',
      }}
      onRedirectCallback={(appState, user) => {
        onExecutePostLogin(user);
      }}
      authorizeTimeoutInSeconds={10}
      useRefreshTokens={true}
      cacheLocation="localstorage"
    >
   [...]
import { GetTokenSilentlyOptions, OAuthError } from '@auth0/auth0-react';
import { GetTokenSilentlyVerboseResponse } from '@auth0/auth0-spa-js';
import { SeverityLevel } from '@sentry/react';
import { DefaultError, queryOptions } from '@tanstack/react-query';
import isNetworkError from 'is-network-error';
import { AuthContext } from '../../routes/__root';
import { authRedirectUrl } from '../../utils/constants';
import { captureException } from '../../utils/error-utils';

export type AuthRefreshTokenDto = { expiresIn: number; sessionToken: string };

/**
 * The maximum number of seconds used only in testing. Below 0.5 seems to cause issues with the test runner.
 */
export const MAX_EXPIRES_SECONDS = 0.5;

/**
 * The minimum number of seconds we should safely refresh the token by.
 */
export const MIN_EXPIRES_SECONDS = 60;

// Load token from cache if available, otherwise force a network request.
let cacheMode: NonNullable<GetTokenSilentlyOptions['cacheMode']> = 'cache-only';

export const authRefreshTokenQueryKey = ['rotating_token'];

export const getRefreshTokenQueryOptions = (auth: AuthContext) =>
  queryOptions<AuthRefreshTokenDto>({
    queryKey: authRefreshTokenQueryKey,
    queryFn: async () => {
      let response: GetTokenSilentlyVerboseResponse = { expires_in: Infinity, access_token: '', id_token: '' };
      try {
        response = await auth.getAccessTokenSilently({
          cacheMode,
          authorizationParams: {
            redirect_uri: authRedirectUrl,
            scope: '...',
            audience: '...',
          },
          timeoutInSeconds: 5,
          detailedResponse: true,
        });
        cacheMode = 'off';
      } catch (err) {
        // See https://community.auth0.com/t/getaccesstokensilently-throws-error-login-required/52333/4
        if (isOAuthError(err)) {
          switch (err.error) {
            case 'login_required':
            case 'invalid_token':
            case 'invalid_grant':
              break;
            /*
             * Thrown when network requests to the Auth server timeout.
             * It can happen if acquiring a lock between tabs takes too long, see:
             * https://github.com/auth0/auth0-spa-js/blob/fbe1344/src/Auth0Client.ts#L689
             * or if the network request to the Auth server times out, see:
             * https://github.com/auth0/auth0-spa-js/blob/fbe1344/src/Auth0Client.ts#L897C50-L897C75
             * Acquiring a lock between tabs can take up 50 secs before throwing, so we
             * set `authorizeTimeoutInSeconds` to 10 secs to minimize the chances of this happening.
             */
            case 'timeout':
            case 'request_error':
              onTokenAccessError(err, 'warning');
              break;
            default:
              onTokenAccessError(err, 'fatal');
          }
        } else if (!isNetworkError(err)) {
          /**
           * TypeError: Failed to fetch
           * - network errors: failure to connect to the server which can be caused by several reasons,
           *   such as slow network and timeout, for example.
           * - CORS errors: when a domain is not authorized to obtain resources from a different domain.
           * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#exceptions
           */
          onTokenAccessError(err, 'fatal');
        }
        // Finally, throw to redirect to logout after handling edge cases above...
        throw err;
      }

      if (response.expires_in > MAX_EXPIRES_SECONDS && response.expires_in < MIN_EXPIRES_SECONDS) {
        const err = new Error('Token expiration is too short');
        onTokenAccessError(err, 'fatal');
        throw err;
      }

      return { expiresIn: response.expires_in * 1000, sessionToken: response.access_token };
    },
    // Allow two retries, one at 1000ms and another at 2000ms.
    retry: shouldRetry,
    // We want to mark the query as stale after the expiresIn time has passed.
    staleTime: (query) => Math.max(query.state.data?.expiresIn ?? 0, MAX_EXPIRES_SECONDS),
    // We want to refetch the query after the expiresIn time has passed minus 10 seconds. This is to ensure we have a fresh token before it expires,
    // and 10 seconds is enough leeway to ensure we don't run into any issues with the token expiring before we can refresh it.
    refetchInterval: (query) => Math.max((query.state.data?.expiresIn ?? 0) - 10, MAX_EXPIRES_SECONDS),
    refetchIntervalInBackground: true,
    // We don't want to refetch on mount, window focus, or reconnect. The query will refetch based on the staleTime and refetchInterval.
    // This is reliable because the query will be marked as stale after the expiresIn time has passed.
    // So long as the query is active, which is the case for every route under the authenticated route, the query will refetch.
    refetchOnMount: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
  });

// See: https://github.com/auth0/auth0-react/blob/main/src/utils.tsx#L18
export function isOAuthError(err: unknown): err is OAuthError {
  return err != null && typeof err === 'object' && 'error' in err && typeof err.error === 'string';
}

// Something we didn't account for happened, send to Sentry and proceed.
function onTokenAccessError(error: unknown, level: SeverityLevel = 'error') {
  captureException(error, {
    level,
    tags: { context: 'auth', action: 'get-access-token-silently', route: window.location.toString() },
  });
}

export function shouldRetry(failureCount: number, err: DefaultError) {
  if (isOAuthError(err)) {
    switch (err.error) {
      case 'login_required' /* https://github.com/auth0/auth0-spa-js/blob/fbe1344/src/Auth0Client.ts#L928 */:
      case 'invalid_token' /* Token expired or revoked permissions */:
      case 'invalid_grant' /* Unknown or invalid refresh token */:
        return false;
    }
  }
  return failureCount < 1;
}

Hi @rendez

Welcome back to the Auth0 Community!

The error that you are receiving seems to be a Sentry related error. It is thrown because your code initializes a value which appears to be undefined or empty. You could also be accessing a property which is empty/null.
I would recommend to review this post from Sentry.

You can also raise an issue on Github on the Sentry-React SDK page regarding the error.

Otherwise, could you please let me know if getTokenSilently() fails to acquire a lock on every time or do some attempts resolve successfully?

Also, could you please let me know what SDKs are you using for your implementation? Are you using only Auth0 React/SpaJS in your implementation?

Let me know if you have any other questions on the matter.

Kind Regards,
Nik

I tried removing Sentry and the error didn’t go away, then I compiled my own auth0-react and spa-js with .esm active in rollup to be able to debug lines without the mangled code. To my surprise the error was gone, so it must have been something with @auth0/auth0-react version 2.2.4 that now seems to work fine in 2.3.0.

Hi again @rendez

Thanks for letting us know that the issue got resolved.

Perhaps it was an error triggered by the outdated version of the react npm package in your environment.

If you have any other questions, feel free to leave a reply or post again on the community!

Kind Regards,
Nik

1 Like

One sort-of-related question… How does exactly cacheMode: ‘on’ or ‘cache-only’ affect whether getTokenSilently issues a request, provided the first issues a request for the token (and user info) by itself, and I only every run getTokenSilently after useAuth0().isLoaded == true?

That is a great question, as mentioned by our Github documentation on Auth0 React:

When off , ignores the cache and always sends a request to Auth0. When cache-only , only reads from the cache and never sends a request to Auth0. Defaults to on , where it both reads from the cache and sends a request to Auth0 as needed.

Hope that clarifies everything!

Kind Regards,
Nik

1 Like