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;
}