@auth0/auth0-spa-js
Version:
Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE
2,389 lines • 79.2 kB
text/typescript
import {
createQueryParams,
runPopup,
parseAuthenticationResult,
encode,
createRandomString,
runIframe,
sha256,
bufferToBase64UrlEncoded,
validateCrypto,
openPopup,
getDomain,
getTokenIssuer,
parseNumber,
stripAuth0Client
} from './utils';
import { getLockManager, type ILockManager } from './lock';
import { oauthToken, revokeToken } from './api';
import { injectDefaultScopes, scopesToRequest } from './scope';
import {
InMemoryCache,
ICache,
CacheKey,
CacheManager,
CacheEntry,
IdTokenEntry,
CACHE_KEY_ID_TOKEN_SUFFIX,
DecodedToken
} from './cache';
import { ConnectAccountTransaction, LoginTransaction, TransactionManager } from './transaction-manager';
import { verify as verifyIdToken } from './jwt';
import {
AuthenticationError,
ConnectError,
GenericError,
InvalidConfigurationError,
MfaRequiredError,
MissingRefreshTokenError,
MissingScopesError,
PopupOpenError,
TimeoutError
} from './errors';
import {
ClientStorage,
CookieStorage,
CookieStorageWithLegacySameSite,
SessionStorage
} from './storage';
import {
CACHE_LOCATION_MEMORY,
DEFAULT_POPUP_CONFIG_OPTIONS,
DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS,
MISSING_REFRESH_TOKEN_ERROR_MESSAGE,
MFA_STEP_UP_ERROR_DESCRIPTION,
DEFAULT_SCOPE,
ONLINE_ACCESS_SCOPE,
DEFAULT_SESSION_CHECK_EXPIRY_DAYS,
DEFAULT_AUTH0_CLIENT,
INVALID_REFRESH_TOKEN_ERROR_MESSAGE,
USER_BLOCKED_ERROR_MESSAGE,
DEFAULT_NOW_PROVIDER,
DEFAULT_FETCH_TIMEOUT_MS,
DEFAULT_AUDIENCE,
SESSION_EXPIRY_LEEWAY_SECONDS
} from './constants';
import {
Auth0ClientOptions,
AuthorizationParams,
AuthorizeOptions,
RedirectLoginOptions,
PopupLoginOptions,
PopupConfigOptions,
RedirectLoginResult,
GetTokenSilentlyOptions,
GetTokenWithPopupOptions,
LogoutOptions,
CacheLocation,
LogoutUrlOptions,
User,
IdToken,
GetTokenSilentlyVerboseResponse,
TokenEndpointResponse,
AuthenticationResult,
ConnectAccountRedirectResult,
RedirectConnectAccountOptions,
ResponseType,
ClientAuthorizationParams,
ClientConfiguration,
RevokeRefreshTokenOptions
} from './global';
// @ts-ignore
import TokenWorker from './worker/token.worker.ts';
import { sendMessage } from './worker/worker.utils';
import { CacheKeyManifest } from './cache/key-manifest';
import {
buildIsAuthenticatedCookieName,
buildOrganizationHintCookieName,
cacheFactory,
getAuthorizeParams,
buildGetTokenSilentlyLockKey,
buildIframeLockKey,
OLD_IS_AUTHENTICATED_COOKIE_NAME,
patchOpenUrlWithOnRedirect,
getScopeToRequest,
isRefreshWithMrrt,
getMissingScopes
} from './Auth0Client.utils';
import { CustomTokenExchangeOptions } from './TokenExchange';
import { Dpop } from './dpop/dpop';
import {
Fetcher,
type FetcherConfig,
type CustomFetchMinimalOutput,
type AuthParams
} from './fetcher';
import { MyAccountApiClient } from './myaccount';
import { MfaApiClient } from './mfa';
import { PasskeyApiClient } from './passkey';
import type { PasskeyCredentialResponse } from './passkey/types';
import { AuthClient as Auth0AuthJsClient } from '@auth0/auth0-auth-js';
import { AnonymousSessionApiClient } from './anonymous';
/**
* @ignore
*/
type GetTokenSilentlyResult = TokenEndpointResponse & {
decodedToken: ReturnType<typeof verifyIdToken>;
scope: string;
oauthTokenScope?: string;
audience: string;
};
/**
* Auth0 SDK for Single Page Applications using [Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce).
*/
export class Auth0Client {
private readonly transactionManager: TransactionManager;
private readonly cacheManager: CacheManager;
private readonly lockManager: ILockManager;
private readonly domainUrl: string;
private readonly tokenIssuer: string;
private readonly scope: Record<string, string>;
private readonly cookieStorage: ClientStorage;
private readonly dpop: Dpop | undefined;
private readonly sessionCheckExpiryDays: number;
private readonly orgHintCookieName: string;
private readonly isAuthenticatedCookieName: string;
private readonly nowProvider: () => number | Promise<number>;
private readonly httpTimeoutMs: number;
private readonly onlineAccess: boolean;
private readonly options: Auth0ClientOptions & {
authorizationParams: ClientAuthorizationParams,
};
private readonly userCache: ICache = new InMemoryCache().enclosedCache;
public readonly myAccount: MyAccountApiClient;
/**
* MFA API client for multi-factor authentication operations.
*
* Provides methods for:
* - Listing enrolled authenticators
* - Enrolling new authenticators (OTP, SMS, Voice, Push, Email)
* - Initiating MFA challenges
* - Verifying MFA challenges
*/
public readonly mfa: MfaApiClient;
/**
* Anonymous Sessions client.
*
* Use `anonymous.createSession()` to establish an anonymous identity before the user logs in.
* Use `anonymous.getTokenSilently()` to obtain or silently renew the access token.
* Use `anonymous.logout()` to end the anonymous session.
* Use `anonymous.getClaims()` to read decoded session token claims (always `null` in EA).
*/
public readonly anonymous: AnonymousSessionApiClient;
/**
* Passkey API client for passwordless authentication.
*
* Provides two single-call methods that handle the full WebAuthn flow internally:
* - `signup(options)` — register a new user with a passkey
* - `login(options?)` — authenticate an existing user with a passkey
*/
public readonly passkey: PasskeyApiClient;
private worker?: Worker;
private readonly authJsClient: Auth0AuthJsClient;
private readonly defaultOptions: Partial<Auth0ClientOptions> = {
authorizationParams: {
scope: DEFAULT_SCOPE
},
useRefreshTokensFallback: false,
useFormData: true,
refreshTokenMode: 'offline',
anonymousSessionsCacheMode: 'localStorage',
};
/** Validates online-access config and returns whether online mode is enabled. */
private resolveOnlineAccess(options: Auth0ClientOptions): boolean {
if (options.refreshTokenMode !== 'online') {
return false;
}
if (options.useRefreshTokens !== true) {
throw new InvalidConfigurationError(
'`refreshTokenMode: "online"` requires the refresh-token grant.',
'Set `useRefreshTokens: true`.'
);
}
if (options.useDpop !== true) {
throw new InvalidConfigurationError(
'`refreshTokenMode: "online"` requires DPoP, which is missing or disabled.',
'Set `useDpop: true` (DPoP is mandatory for online access).'
);
}
return true;
}
/** Warns when the config contradicts Enterprise Connect's constraints. */
private warnEnterpriseConnectConfig(options: Auth0ClientOptions): void {
if (options.enterpriseConnect !== true) {
return;
}
const scope = options.authorizationParams?.scope;
if (
options.useRefreshTokens === true ||
(typeof scope === 'string' && scope.includes('offline_access'))
) {
console.warn(
'Enterprise Connect issues no refresh token; `useRefreshTokens` and `offline_access` in `scope` have no effect.'
);
}
if (options.authorizationParams?.organization) {
console.warn(
'Enterprise Connect resolves the organization from the email domain (Home Realm Discovery); a static `organization` breaks multi-customer setups.'
);
}
}
constructor(options: Auth0ClientOptions) {
this.onlineAccess = this.resolveOnlineAccess(options);
this.warnEnterpriseConnectConfig(options);
this.options = {
...this.defaultOptions,
...options,
authorizationParams: {
...this.defaultOptions.authorizationParams,
...options.authorizationParams
}
};
typeof window !== 'undefined' && validateCrypto();
this.lockManager = getLockManager();
if (options.cache && options.cacheLocation) {
console.warn(
'Both `cache` and `cacheLocation` options have been specified in the Auth0Client configuration; ignoring `cacheLocation` and using `cache`.'
);
}
let cacheLocation: CacheLocation | undefined;
let cache: ICache;
if (options.cache) {
cache = options.cache;
} else {
cacheLocation = options.cacheLocation || CACHE_LOCATION_MEMORY;
if (!cacheFactory(cacheLocation)) {
throw new Error(`Invalid cache location "${cacheLocation}"`);
}
cache = cacheFactory(cacheLocation)();
}
this.httpTimeoutMs = options.httpTimeoutInSeconds
? options.httpTimeoutInSeconds * 1000
: DEFAULT_FETCH_TIMEOUT_MS;
this.cookieStorage =
options.legacySameSiteCookie === false
? CookieStorage
: CookieStorageWithLegacySameSite;
this.orgHintCookieName = buildOrganizationHintCookieName(
this.options.clientId
);
this.isAuthenticatedCookieName = buildIsAuthenticatedCookieName(
this.options.clientId
);
this.sessionCheckExpiryDays =
options.sessionCheckExpiryDays || DEFAULT_SESSION_CHECK_EXPIRY_DAYS;
const transactionStorage = options.useCookiesForTransactions
? this.cookieStorage
: SessionStorage;
// `online_access` and `offline_access` are mutually exclusive — inject at most one.
let sessionScope = '';
if (this.onlineAccess) {
sessionScope = ONLINE_ACCESS_SCOPE;
} else if (this.options.useRefreshTokens) {
sessionScope = 'offline_access';
}
this.scope = injectDefaultScopes(
this.options.authorizationParams.scope,
'openid',
sessionScope
);
this.transactionManager = new TransactionManager(
transactionStorage,
this.options.clientId,
this.options.cookieDomain
);
this.nowProvider = this.options.nowProvider || DEFAULT_NOW_PROVIDER;
this.cacheManager = new CacheManager(
cache,
!cache.allKeys
? new CacheKeyManifest(cache, this.options.clientId)
: undefined,
this.nowProvider
);
this.dpop = this.options.useDpop
? new Dpop(this.options.clientId)
: undefined;
this.domainUrl = getDomain(this.options.domain);
this.tokenIssuer = getTokenIssuer(this.options.issuer, this.domainUrl);
const myAccountApiIdentifier = `${this.domainUrl}/me/`;
const myAccountFetcher = this.createFetcher({
...(this.options.useDpop && { dpopNonceId: '__auth0_my_account_api__' }),
getAccessToken: (authParams?: AuthParams) =>
this.getTokenSilently({
authorizationParams: {
scope: authParams?.scope?.join(' '),
audience: myAccountApiIdentifier
},
detailedResponse: true
})
});
this.myAccount = new MyAccountApiClient(
myAccountFetcher,
myAccountApiIdentifier
);
// Initialize auth-js client foundational Oauth feature support
this.authJsClient = new Auth0AuthJsClient({
domain: this.options.domain,
clientId: this.options.clientId,
});
this.mfa = new MfaApiClient(this.authJsClient.mfa, this);
this.anonymous = new AnonymousSessionApiClient(
this.authJsClient.anonymous,
this.options.clientId,
this.options.anonymousSessionsCacheMode,
this.lockManager
);
this.passkey = new PasskeyApiClient(
this.authJsClient.passkey,
this
);
// Don't use web workers unless using refresh tokens in memory.
if (
typeof window !== 'undefined' &&
window.Worker &&
this.options.useRefreshTokens &&
cacheLocation === CACHE_LOCATION_MEMORY
) {
if (this.options.workerUrl) {
this.worker = new Worker(this.options.workerUrl);
} else {
this.worker = new TokenWorker();
}
this.worker!.postMessage({
type: 'init',
allowedBaseUrl: this.domainUrl
});
}
}
/**
* Returns a readonly copy of the initialization configuration.
*
* @returns An object containing domain and clientId
*
* @example
* ```typescript
* const auth0 = new Auth0Client({
* domain: 'tenant.auth0.com',
* clientId: 'abc123'
* });
*
* const config = auth0.getConfiguration();
* // { domain: 'tenant.auth0.com', clientId: 'abc123' }
* ```
*/
public getConfiguration(): Readonly<ClientConfiguration> {
return Object.freeze({
domain: this.options.domain,
clientId: this.options.clientId
});
}
private _url(path: string) {
const auth0ClientObj = this.options.auth0Client || DEFAULT_AUTH0_CLIENT;
// Strip env from auth0Client for /authorize to prevent query param truncation
const strippedAuth0Client = stripAuth0Client(auth0ClientObj, true);
const auth0Client = encodeURIComponent(
btoa(JSON.stringify(strippedAuth0Client))
);
return `${this.domainUrl}${path}&auth0Client=${auth0Client}`;
}
private _authorizeUrl(authorizeOptions: AuthorizeOptions) {
return this._url(`/authorize?${createQueryParams(authorizeOptions)}`);
}
private async _verifyIdToken(
id_token: string,
nonce?: string,
organization?: string
) {
const now = await this.nowProvider();
return verifyIdToken({
iss: this.tokenIssuer,
aud: this.options.clientId,
id_token,
nonce,
organization,
leeway: this.options.leeway,
max_age: parseNumber(this.options.authorizationParams.max_age),
now
});
}
private _processOrgHint(organization?: string) {
if (organization) {
this.cookieStorage.save(this.orgHintCookieName, organization, {
daysUntilExpire: this.sessionCheckExpiryDays,
cookieDomain: this.options.cookieDomain
});
} else {
this.cookieStorage.remove(this.orgHintCookieName, {
cookieDomain: this.options.cookieDomain
});
}
}
/**
* Extracts the session transfer token from the current URL query parameters
* for Native to Web SSO flows.
*
* @param paramName The query parameter name to extract from the URL
* @returns The session transfer token if present, undefined otherwise
*/
private _extractSessionTransferToken(paramName: string): string | undefined {
const params = new URLSearchParams(window.location.search);
return params.get(paramName) || undefined;
}
/**
* Clears the session transfer token from the current URL using the History API.
* This prevents the token from being re-sent on subsequent authentication requests,
* which is important since session transfer tokens are typically single-use.
*
* @param paramName The query parameter name to remove from the URL
*/
private _clearSessionTransferTokenFromUrl(paramName: string): void {
try {
const url = new URL(window.location.href);
if (url.searchParams.has(paramName)) {
url.searchParams.delete(paramName);
window.history.replaceState({}, '', url.toString());
}
} catch {
// Silently fail if URL manipulation isn't possible
}
}
/**
* Applies the session transfer token from the URL to the authorization parameters
* if configured and not already provided.
*
* @param authorizationParams The authorization parameters to enhance
* @returns The authorization parameters with session_transfer_token added if applicable
*/
private _applySessionTransferToken(
authorizationParams: AuthorizationParams
): AuthorizationParams {
const paramName = this.options.sessionTransferTokenQueryParamName;
if (!paramName || authorizationParams.session_transfer_token) {
return authorizationParams;
}
const token = this._extractSessionTransferToken(paramName);
if (!token) return authorizationParams;
this._clearSessionTransferTokenFromUrl(paramName);
return { ...authorizationParams, session_transfer_token: token };
}
private async _prepareAuthorizeUrl(
authorizationParams: AuthorizationParams,
authorizeOptions?: Partial<AuthorizeOptions>,
fallbackRedirectUri?: string
): Promise<{
scope: string;
audience: string;
redirect_uri?: string;
nonce: string;
code_verifier: string;
state: string;
url: string;
}> {
const state = encode(createRandomString());
const nonce = encode(createRandomString());
const code_verifier = createRandomString();
const code_challengeBuffer = await sha256(code_verifier);
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
const thumbprint = await this.dpop?.calculateThumbprint();
const params = getAuthorizeParams(
this.options,
this.scope,
authorizationParams,
state,
nonce,
code_challenge,
authorizationParams.redirect_uri ||
this.options.authorizationParams.redirect_uri ||
fallbackRedirectUri,
authorizeOptions?.response_mode,
thumbprint
);
const url = this._authorizeUrl(params);
return {
nonce,
code_verifier,
scope: params.scope,
audience: params.audience || DEFAULT_AUDIENCE,
redirect_uri: params.redirect_uri,
state,
url
};
}
/**
* ```js
* try {
* await auth0.loginWithPopup(options);
* } catch(e) {
* if (e instanceof PopupCancelledError) {
* // Popup was closed before login completed
* }
* }
* ```
*
* Opens a popup with the `/authorize` URL using the parameters
* provided as arguments. Random and secure `state` and `nonce`
* parameters will be auto-generated. If the response is successful,
* results will be valid according to their expiration times.
*
* IMPORTANT: This method has to be called from an event handler
* that was started by the user like a button click, for example,
* otherwise the popup will be blocked in most browsers.
*
* @param options
* @param config
*/
public async loginWithPopup(
options?: PopupLoginOptions,
config?: PopupConfigOptions
) {
options = options || {};
config = config || {};
if (!config.popup) {
config.popup = openPopup('');
if (!config.popup) {
throw new PopupOpenError();
}
}
const authorizationParams = this._applySessionTransferToken(options.authorizationParams || {});
const params = await this._prepareAuthorizeUrl(
authorizationParams,
{ response_mode: 'web_message' },
window.location.origin
);
config.popup.location.href = params.url;
const codeResult = await runPopup(
{
...config,
timeoutInSeconds:
config.timeoutInSeconds ||
this.options.authorizeTimeoutInSeconds ||
DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
},
new URL(params.url).origin
);
if (params.state !== codeResult.state) {
throw new GenericError('state_mismatch', 'Invalid state');
}
const organization =
options.authorizationParams?.organization ||
this.options.authorizationParams.organization;
await this._requestToken(
{
audience: params.audience,
scope: params.scope,
code_verifier: params.code_verifier,
grant_type: 'authorization_code',
code: codeResult.code as string,
redirect_uri: params.redirect_uri
},
{
nonceIn: params.nonce,
organization
}
);
}
/**
* ```js
* const user = await auth0.getUser();
* ```
*
* Returns the user information if available (decoded
* from the `id_token`).
*
* @typeparam TUser The type to return, has to extend {@link User}.
*/
public async getUser<TUser extends User>(): Promise<TUser | undefined> {
if (await this._isSessionCeilingReached()) {
return undefined;
}
const cache = await this._getIdTokenFromCache();
return cache?.decodedToken?.user as TUser;
}
/**
* ```js
* const claims = await auth0.getIdTokenClaims();
* ```
*
* Returns all claims from the id_token if available.
*/
public async getIdTokenClaims(): Promise<IdToken | undefined> {
if (await this._isSessionCeilingReached()) {
return undefined;
}
const cache = await this._getIdTokenFromCache();
return cache?.decodedToken?.claims;
}
/**
* ```js
* await auth0.loginWithRedirect(options);
* ```
*
* Performs a redirect to `/authorize` using the parameters
* provided as arguments. Random and secure `state` and `nonce`
* parameters will be auto-generated.
*
* @param options
*/
public async loginWithRedirect<TAppState = any>(
options: RedirectLoginOptions<TAppState> = {}
) {
const { openUrl, fragment, appState, ...urlOptions } =
patchOpenUrlWithOnRedirect(options);
const organization =
urlOptions.authorizationParams?.organization ||
this.options.authorizationParams.organization;
const authorizationParams = this._applySessionTransferToken(urlOptions.authorizationParams || {});
const { url, ...transaction } = await this._prepareAuthorizeUrl(
authorizationParams
);
this.transactionManager.create<LoginTransaction>({
...transaction,
appState,
response_type: ResponseType.Code,
...(organization && { organization })
});
const urlWithFragment = fragment ? `${url}#${fragment}` : url;
if (openUrl) {
await openUrl(urlWithFragment);
} else {
window.location.assign(urlWithFragment);
}
}
/**
* After the browser redirects back to the callback page,
* call `handleRedirectCallback` to handle success and error
* responses from Auth0. If the response is successful, results
* will be valid according to their expiration times.
*/
public async handleRedirectCallback<TAppState = any>(
url: string = window.location.href
): Promise<
RedirectLoginResult<TAppState> | ConnectAccountRedirectResult<TAppState>
> {
const queryStringFragments = url.split('?').slice(1);
if (queryStringFragments.length === 0) {
throw new Error('There are no query params available for parsing.');
}
const transaction = this.transactionManager.get<
LoginTransaction | ConnectAccountTransaction
>();
if (!transaction) {
throw new GenericError('missing_transaction', 'Invalid state');
}
this.transactionManager.remove();
const authenticationResult = parseAuthenticationResult(
queryStringFragments.join('')
);
if (transaction.response_type === ResponseType.ConnectCode) {
return this._handleConnectAccountRedirectCallback<TAppState>(
authenticationResult,
transaction
);
}
return this._handleLoginRedirectCallback<TAppState>(
authenticationResult,
transaction
);
}
/**
* Handles the redirect callback from the login flow.
*
* @template AppState - The application state persisted from the /authorize redirect.
* @param {string} authenticationResult - The parsed authentication result from the URL.
* @param {string} transaction - The login transaction.
*
* @returns {RedirectLoginResult} Resolves with the persisted app state.
* @throws {GenericError | Error} If the transaction is missing, invalid, or the code exchange fails.
*/
private async _handleLoginRedirectCallback<TAppState>(
authenticationResult: AuthenticationResult,
transaction: LoginTransaction
): Promise<RedirectLoginResult<TAppState>> {
const { code, state, error, error_description } = authenticationResult;
if (error) {
throw new AuthenticationError(
error,
error_description || error,
state,
transaction.appState
);
}
// Transaction should have a `code_verifier` to do PKCE for CSRF protection
if (
!transaction.code_verifier ||
(transaction.state && transaction.state !== state)
) {
throw new GenericError('state_mismatch', 'Invalid state');
}
const organization = transaction.organization;
const nonceIn = transaction.nonce;
const redirect_uri = transaction.redirect_uri;
await this._requestToken(
{
audience: transaction.audience,
scope: transaction.scope,
code_verifier: transaction.code_verifier,
grant_type: 'authorization_code',
code: code as string,
...(redirect_uri ? { redirect_uri } : {})
},
{ nonceIn, organization }
);
return {
appState: transaction.appState,
response_type: ResponseType.Code
};
}
/**
* Handles the redirect callback from the connect account flow.
* This works the same as the redirect from the login flow expect it verifies the `connect_code`
* with the My Account API rather than the `code` with the Authorization Server.
*
* @template AppState - The application state persisted from the connect redirect.
* @param {string} connectResult - The parsed connect accounts result from the URL.
* @param {string} transaction - The login transaction.
* @returns {Promise<ConnectAccountRedirectResult>} The result of the My Account API, including any persisted app state.
* @throws {GenericError | MyAccountApiError} If the transaction is missing, invalid, or an error is returned from the My Account API.
*/
private async _handleConnectAccountRedirectCallback<TAppState>(
connectResult: AuthenticationResult,
transaction: ConnectAccountTransaction
): Promise<ConnectAccountRedirectResult<TAppState>> {
const { connect_code, state, error, error_description } = connectResult;
if (error) {
throw new ConnectError(
error,
error_description || error,
transaction.connection,
state,
transaction.appState
);
}
if (!connect_code) {
throw new GenericError('missing_connect_code', 'Missing connect code');
}
if (
!transaction.code_verifier ||
!transaction.state ||
!transaction.auth_session ||
!transaction.redirect_uri ||
transaction.state !== state
) {
throw new GenericError('state_mismatch', 'Invalid state');
}
const data = await this.myAccount.completeAccount({
auth_session: transaction.auth_session,
connect_code,
redirect_uri: transaction.redirect_uri,
code_verifier: transaction.code_verifier
});
return {
...data,
appState: transaction.appState,
response_type: ResponseType.ConnectCode,
};
}
/**
* ```js
* await auth0.checkSession();
* ```
*
* Check if the user is logged in using `getTokenSilently`. The difference
* with `getTokenSilently` is that this doesn't return a token, but it will
* pre-fill the token cache.
*
* This method also heeds the `auth0.{clientId}.is.authenticated` cookie, as an optimization
* to prevent calling Auth0 unnecessarily. If the cookie is not present because
* there was no previous login (or it has expired) then tokens will not be refreshed.
*
* It should be used for silently logging in the user when you instantiate the
* `Auth0Client` constructor. You should not need this if you are using the
* `createAuth0Client` factory.
*
* **Note:** the cookie **may not** be present if running an app using a private tab, as some
* browsers clear JS cookie data and local storage when the tab or page is closed, or on page reload. This effectively
* means that `checkSession` could silently return without authenticating the user on page refresh when
* using a private tab, despite having previously logged in. As a workaround, use `getTokenSilently` instead
* and handle the possible `login_required` error [as shown in the readme](https://github.com/auth0/auth0-spa-js#creating-the-client).
*
* @param options
*/
private async _maybeCreateAnonymousSession() {
if (this.options.createAnonymousSessionOnFailedSilentAuth) {
if (this.anonymous.hasSession()) {
return;
}
try {
// No audience is requested here. This establishes the session token only.
// The app calls anonymous.getTokenSilently({ audience }) separately for each
// resource server and those calls reuse the session token without creating a
// new identity.
await this.anonymous.getTokenSilently();
} catch (e) {
// eslint-disable-next-line no-console
console.debug('[auth0-spa-js] Anonymous session creation failed', e);
}
}
}
public async checkSession(options?: GetTokenSilentlyOptions) {
if (!this.cookieStorage.get(this.isAuthenticatedCookieName)) {
if (!this.cookieStorage.get(OLD_IS_AUTHENTICATED_COOKIE_NAME)) {
await this._maybeCreateAnonymousSession();
return;
} else {
// Migrate the existing cookie to the new name scoped by client ID
this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
daysUntilExpire: this.sessionCheckExpiryDays,
cookieDomain: this.options.cookieDomain
});
this.cookieStorage.remove(OLD_IS_AUTHENTICATED_COOKIE_NAME);
}
}
try {
await this.getTokenSilently(options);
} catch (e) {
if (
e instanceof GenericError &&
e.error === 'login_required' &&
e.error_description !== MFA_STEP_UP_ERROR_DESCRIPTION
) {
await this._maybeCreateAnonymousSession();
}
}
}
/**
* Fetches a new access token and returns the response from the /oauth/token endpoint, omitting the refresh token.
*
* @param options
*/
public async getTokenSilently(
options: GetTokenSilentlyOptions & { detailedResponse: true }
): Promise<GetTokenSilentlyVerboseResponse | undefined>;
/**
* Fetches a new access token and returns it.
*
* @param options
*/
public async getTokenSilently(
options?: GetTokenSilentlyOptions
): Promise<string | undefined>;
/**
* Fetches a new access token, and either returns just the access token (the default) or the response from the /oauth/token endpoint, depending on the `detailedResponse` option.
*
* ```js
* const token = await auth0.getTokenSilently(options);
* ```
*
* If there's a valid token stored and it has more than 60 seconds
* remaining before expiration, return the token. Otherwise, attempt
* to obtain a new token.
*
* A new token will be obtained either by opening an iframe or a
* refresh token (if `useRefreshTokens` is `true`).
* If iframes are used, opens an iframe with the `/authorize` URL
* using the parameters provided as arguments. Random and secure `state`
* and `nonce` parameters will be auto-generated. If the response is successful,
* results will be validated according to their expiration times.
*
* If refresh tokens are used, the token endpoint is called directly with the
* 'refresh_token' grant. If no refresh token is available to make this call,
* the SDK will only fall back to using an iframe to the '/authorize' URL if
* the `useRefreshTokensFallback` setting has been set to `true`. By default this
* setting is `false`.
*
* This method may use a web worker to perform the token call if the in-memory
* cache is used.
*
* If an `audience` value is given to this function, the SDK always falls
* back to using an iframe to make the token exchange.
*
* Note that in all cases, falling back to an iframe requires access to
* the `auth0` cookie.
*
* @param options
*/
public async getTokenSilently(
options: GetTokenSilentlyOptions = {}
): Promise<undefined | string | GetTokenSilentlyVerboseResponse> {
const localOptions: GetTokenSilentlyOptions & {
authorizationParams: AuthorizationParams & { scope: string };
} = {
cacheMode: 'on',
...options,
authorizationParams: {
...this.options.authorizationParams,
...options.authorizationParams,
scope: scopesToRequest(
this.scope,
options.authorizationParams?.scope,
options.authorizationParams?.audience || this.options.authorizationParams.audience,
)
}
};
const result = await this._getTokenSilently(localOptions);
return options.detailedResponse ? result : result?.access_token;
}
private async _getTokenSilently(
options: GetTokenSilentlyOptions & {
authorizationParams: AuthorizationParams & { scope: string };
}
): Promise<undefined | GetTokenSilentlyVerboseResponse> {
const { cacheMode, ...getTokenOptions } = options;
if (await this._isSessionCeilingReached()) {
return undefined;
}
// Check the cache before acquiring the lock to avoid the latency of
// `lock.acquireLock` when the cache is populated.
if (cacheMode !== 'off') {
const entry = await this._getEntryFromCache({
scope: getTokenOptions.authorizationParams.scope,
audience: getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId,
cacheMode,
});
if (entry) {
return entry;
}
}
if (cacheMode === 'cache-only') {
return;
}
// Generate lock key based on client ID and audience for better isolation
const lockKey = buildGetTokenSilentlyLockKey(
this.options.clientId,
getTokenOptions.authorizationParams.audience || 'default'
);
try {
return await this.lockManager.runWithLock(lockKey, 5000, async () => {
// Check the cache a second time, because it may have been populated
// by a previous call while this call was waiting to acquire the lock.
if (cacheMode !== 'off') {
const entry = await this._getEntryFromCache({
scope: getTokenOptions.authorizationParams.scope,
audience:
getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId
});
if (entry) {
return entry;
}
}
const authResult = this.options.useRefreshTokens
? await this._getTokenUsingRefreshToken(getTokenOptions)
: await this._getTokenFromIFrame(getTokenOptions);
const { id_token, token_type, access_token, oauthTokenScope, expires_in } =
authResult;
return {
id_token,
token_type,
access_token,
...(oauthTokenScope ? { scope: oauthTokenScope } : null),
expires_in
};
});
} catch (error) {
// Lock is already released - safe to open popup
if (this._isInteractiveError(error) && this.options.interactiveErrorHandler === 'popup') {
return await this._handleInteractiveErrorWithPopup(getTokenOptions);
}
throw error;
}
}
/**
* Checks if an error should be handled by the interactive error handler.
* Matches:
* - MfaRequiredError (refresh token path, error='mfa_required')
* - GenericError from iframe path (error='login_required',
* error_description='Multifactor authentication required')
* Extensible for future interactive error types.
*/
private _isInteractiveError(
error: unknown
): error is MfaRequiredError | GenericError {
return error instanceof MfaRequiredError || (error instanceof GenericError && this._isIframeMfaError(error));
}
/**
* Checks if a login_required error from the iframe flow is actually
* an MFA step-up requirement. The /authorize endpoint returns
* error='login_required' with error_description='Multifactor authentication required'
* when MFA is needed but prompt=none prevents interaction.
*/
private _isIframeMfaError(error: GenericError): boolean {
return (
error.error === 'login_required' &&
error.error_description === MFA_STEP_UP_ERROR_DESCRIPTION
);
}
/**
* Handles MFA errors by opening a popup to complete authentication,
* then reads the resulting token from cache.
*/
private async _handleInteractiveErrorWithPopup(
options: GetTokenSilentlyOptions & {
authorizationParams: AuthorizationParams & { scope: string };
}
): Promise<GetTokenSilentlyVerboseResponse> {
try {
await this.loginWithPopup({
authorizationParams: options.authorizationParams
});
const entry = await this._getEntryFromCache({
scope: options.authorizationParams.scope,
audience:
options.authorizationParams.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId
});
if (!entry) {
throw new GenericError(
'interactive_handler_cache_miss',
'Token not found in cache after interactive authentication'
);
}
return entry;
} catch (error) {
// Expected errors (all GenericError subclasses):
// - PopupCancelledError: user closed the popup before completing login
// - PopupTimeoutError: popup did not complete within the allowed time
// - PopupOpenError: popup could not be opened (e.g. blocked by browser)
// - GenericError: authentication or cache miss errors
throw error;
}
}
/**
* ```js
* const token = await auth0.getTokenWithPopup(options);
* ```
* Opens a popup with the `/authorize` URL using the parameters
* provided as arguments. Random and secure `state` and `nonce`
* parameters will be auto-generated. If the response is successful,
* results will be valid according to their expiration times.
*
* @param options
* @param config
*/
public async getTokenWithPopup(
options: GetTokenWithPopupOptions = {},
config: PopupConfigOptions = {}
) {
const localOptions = {
...options,
authorizationParams: {
...this.options.authorizationParams,
...options.authorizationParams,
scope: scopesToRequest(
this.scope,
options.authorizationParams?.scope,
options.authorizationParams?.audience || this.options.authorizationParams.audience
)
}
};
config = {
...DEFAULT_POPUP_CONFIG_OPTIONS,
...config
};
await this.loginWithPopup(localOptions, config);
const cache = await this.cacheManager.get(
new CacheKey({
scope: localOptions.authorizationParams.scope,
audience: localOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId
}),
undefined,
this.options.useMrrt
);
return cache!.access_token;
}
/**
* ```js
* const isAuthenticated = await auth0.isAuthenticated();
* ```
*
* Returns `true` if there's valid information stored,
* otherwise returns `false`.
*
*/
public async isAuthenticated() {
const user = await this.getUser();
return !!user;
}
/**
* ```js
* await auth0.buildLogoutUrl(options);
* ```
*
* Builds a URL to the logout endpoint using the parameters provided as arguments.
* @param options
*/
private _buildLogoutUrl(options: LogoutUrlOptions): string {
if (options.clientId !== null) {
options.clientId = options.clientId || this.options.clientId;
} else {
delete options.clientId;
}
const { federated, ...logoutOptions } = options.logoutParams || {};
const federatedQuery = federated ? `&federated` : '';
const url = this._url(
`/v2/logout?${createQueryParams({
clientId: options.clientId,
...logoutOptions
})}`
);
return url + federatedQuery;
}
/**
* ```js
* await auth0.revokeRefreshToken();
* ```
*
* Revokes the refresh token using the `/oauth/revoke` endpoint.
* This invalidates the refresh token so it can no longer be used to obtain new access tokens.
*
* The method works with both memory and localStorage cache modes:
* - For memory storage with worker: The refresh token never leaves the worker thread,
* maintaining security isolation
* - For localStorage: The token is retrieved from cache and revoked
*
* If `useRefreshTokens` is disabled, this method does nothing.
*
* **Online mode:** when `refreshTokenMode` is `'online'`, revoking the ORT via
* `/oauth/revoke` **also terminates the Auth0 session** and **clears the entire local
* cache** (access token, ID token, user profile). Because Online Refresh Tokens are
* session-bound, the authorization server ties the token directly to the session —
* revoking the ORT invalidates the session server-side. The local cache is cleared
* immediately so that `isAuthenticated()` returns `false` and `getUser()` returns
* `undefined` right away, without waiting for the access token to expire.
* Use this when you need to force a sign-out without a redirect (e.g. background
* revocation). For a redirect-based sign-out, prefer `logout()`.
*
* **Important:** This method revokes the refresh token for a single audience. If your
* application requests tokens for multiple audiences, each audience may have its own
* refresh token. To fully revoke all refresh tokens, call this method once per audience.
* If you want to terminate the user's session with a redirect, use `logout()` instead.
*
* When using Multi-Resource Refresh Tokens (MRRT), a single refresh token may cover
* multiple audiences. In that case, revoking it will affect all cache entries that
* share the same token.
*
* @param options - Optional parameters to identify which refresh token to revoke.
* Defaults to the audience configured in `authorizationParams`.
*
* @example
* // Revoke the default refresh token
* await auth0.revokeRefreshToken();
*
* @example
* // Revoke refresh tokens for each audience individually
* await auth0.revokeRefreshToken({ audience: 'https://api.example.com' });
* await auth0.revokeRefreshToken({ audience: 'https://api2.example.com' });
*/
public async revokeRefreshToken(options: RevokeRefreshTokenOptions = {}): Promise<void> {
if (!this.options.useRefreshTokens) {
return;
}
const audience =
options.audience || this.options.authorizationParams.audience;
const resolvedAudience = audience || DEFAULT_AUDIENCE;
// For the non-worker path the main-thread cache holds the refresh tokens.
// For the worker path the worker holds its own RT store — the cache returns
// [] and revokeToken sends a single message; the worker loops internally.
const refreshTokens = await this.cacheManager.getRefreshTokensByAudience(
resolvedAudience,
this.options.clientId
);
await revokeToken(
{
baseUrl: this.domainUrl,
timeout: this.httpTimeoutMs,
auth0Client: this.options.auth0Client,
useFormData: this.options.useFormData,
client_id: this.options.clientId,
refreshTokens,
audience: resolvedAudience,
onRefreshTokenRevoked: refreshToken =>
this.cacheManager.stripRefreshToken(refreshToken)
},
this.worker
);
// In online mode the ORT is session-bound: revoking it terminates the Auth0 session
// server-side. Clear the entire local cache so that isAuthenticated() returns false
// and getUser() returns undefined immediately — the stale access token and ID token
// must not remain visible to the application after the session is gone.
if (this.onlineAccess) {
await this._clearLocalSession();
}
}
/**
* ```js
* await auth0.logout(options);
* ```
*
* Clears the application session and performs a redirect to `/v2/logout`, using
* the parameters provided as arguments, to clear the Auth0 session.
*
* If the `federated` option is specified it also clears the Identity Provider session.
* [Read more about how Logout works at Auth0](https://auth0.com/docs/logout).
*
* @param options
*/
public async logout(options: LogoutOptions = {}): Promise<void> {
if (
this.options.enterpriseConnect &&
options.logoutParams?.federated !== true
) {
console.warn(
'Enterprise Connect logout without `federated: true` leaves the enterprise IdP session alive; the next login may silently reuse the previous user.'
);
}
const { openUrl, ...logoutOptions } = patchOpenUrlWithOnRedirect(options);
await this._clearLocalSession(options.clientId);
const url = this._buildLogoutUrl(logoutOptions);
if (openUrl) {
await openUrl(url);
} else if (openUrl !== false) {
window.location.assign(url);
}
}
private async _getTokenFromIFrame(
options: GetTokenSilentlyOptions & {
authorizationParams: AuthorizationParams & { scope: string };
}
): Promise<GetTokenSilentlyResult> {
const iframeLockKey = buildIframeLockKey(this.options.clientId);
// Acquire global iframe lock to serialize iframe authorization flows.
// This is necessary because the SDK does not support multiple simultaneous transactions.
// Since https://github.com/auth0/auth0-spa-js/pull/1408, when calling
// `getTokenSilently()`, the global locking will lock per `audience` instead of locking
// only per `client_id`.
// This means that calls for different audiences would happen in parallel, which does
// not work when using silent authentication (prompt=none) from within the SDK, as that
// relies on the same transaction context as a top-level `loginWithRedirect`.
// To resolve that, we add a second-level locking that locks only the iframe calls in
// the same way as was done before https://github.com/auth0/auth0-spa-js/pull/1408.
try {
return await this.lockManager.runWithLock(
iframeLockKey,
5000,
async () => {
const params: AuthorizationParams & { scope: string } = {
...options.authorizationParams,
prompt: 'none'
};
const orgHint = this.cookieStorage.get<string>(
this.orgHintCookieName
);
if (orgHint && !params.organization) {
params.organization = orgHint;
}
const {
url,
state: stateIn,
nonce: nonceIn,
code_verifier,
redirect_uri,
scope,
audience
} = await this._prepareAuthorizeUrl(
params,
{ response_mode: 'web_message' },
window.location.origin
);
// When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
// It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
// https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
if ((window as any).crossOriginIsolated) {
throw new GenericError(
'login_required',
'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
);
}
const authorizeTimeout =
options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
// Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
let eventOrigin: string;
try {
eventOrigin = new URL(this.domainUrl).origin;
} catch {
eventOrigin = this.domainUrl;
}
const codeResult = await runIframe(
url,
eventOrigin,
authorizeTimeout
);
if (stateIn !== codeResult.state) {
throw new GenericError('state_mismatch', 'Invalid state');
}
const tokenResult = await this._requestToken(
{
...options.authorizationParams,
code_verifier,
code: codeResult.code as string,
grant_type: 'authorization_code',
redirect_uri,
timeout: options.authorizationParams.timeout || this.httpTimeoutMs
},
{
nonceIn,
organization: params.organization
}
);
return {
...tokenResult,
scope: scope,
oauthTokenScope: tokenResult.scope,
audience: audience
};
}
);
} catch (e) {
if (e.error === 'login_required') {
// When the login_required error is actually an MFA step-up requirement
// and the interactive error handler is configured, skip logout so the
// session is preserved for the popup flow.
const shouldSkipLogoutForMfaStepUp =
e instanceof GenericError &&
this._isIframeMfaError(e) &&
this.options.interactiveErrorHandler === 'popup';
if (!shouldSkipLogoutForMfaStepUp) {
this.logout({
openUrl: false
});
}
}
throw e;
}
}
private async _getTokenUsingRefreshToken(
options: GetTokenSilentlyOptions & {
authorizationParams: AuthorizationParams & { scope: string };
}
): Promise<GetTokenSilentlyResult> {
const cache = await this.cacheManager.get(
new CacheKey({
scope: options.authorizationParams.scope,
audience: options.authorizationParams.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId
}),
undefined,
this.options.useMrrt
);
// If you don't have a refresh token in memory
// and you don't have a refresh token in web worker memory
// and useRefreshTokensFallback was explicitly enabled
// fallback to an iframe
if ((!cache || !cache.refresh_token) && !this.worker) {
if (this.options.useRefreshTokensFallback) {
return await this._getTokenFromIFrame(options);
}
throw new MissingRefreshTokenError(
options.authorizationParams.audience || DEFAULT_AUDIENCE,
options.authorizationParams.scope
);
}
const redirect_uri =
options.authorizationParams.redirect_uri ||
this.options.authorizationParams.redirect_uri ||
window.location.origin;
const timeout =
typeof options.timeoutInSeconds === 'number'
? options.timeoutInSeconds * 1000
: null;
const scopesToRequest = getScopeToRequest(
this.options.useMrrt,
options.authorizationParams,
cache?.audience,
cache?.scope,
);
try {
const tokenResult = await this._requestToken({
...options.authorizationParams,
grant_type: 'refresh_token',
refresh_token: cache && cache.refresh_token,
redirect_uri,
...(timeout && { timeout })
},
{
scopesToRequest,
}
);
await this._propagateRotatedRefreshToken(
cache?.refresh_token,
tokenResult.refresh_token
);
// Some scopes requested to the server might not be inside the refresh policies
// In order to return a token with all requested scopes when using MRRT we should
// check if all scopes are returned. If not, we will try to use an iframe to request
// a token.
if (this.options.useMrrt) {
const isRefreshMrrt = isRefreshWithMrrt(
cache?.audience,
cache?.scope,
options.authorizationParams.audience,
options.authorizationParams.scope,
);
if (isRefreshMrrt) {
const missingScopes = getMissingScopes(
scopesToRequest,
tokenResult.scope,
this.onlineAccess
);
if (missingScopes) {
if (this.options.useRefreshTokensFallback) {
return await this._getTokenFromIFrame(options);
}
// Before throwing MissingScopesError, we have to remove the previously created entry
// to avoid storing wrong data
await this.cacheManager.remove(
this.options.clientId,
options.authorizationParams.audience,
options.authorizationParams.scope,
);
throw new MissingScopesError(
options.authorizationParams.audience || 'default',
missingScopes,
);
}
}
}
return {
...tokenResult,
scope: options.authorizationParams.scope,
oauthTokenScope: tokenResult.scope,
audience: options.authorizationParams.audience || DEFAULT_AUDIENCE
};
} catch (e) {
if (e.message) {
// Blocked users should be logged out immediately. No point attempting
// iframe fallback as the authorization server will reject the request.
if (e.message.includes(USER_BLOCKED_ERROR_MESSAGE)) {
await this.logout({ openUrl: false });
throw e;
}
// For missing or invalid refresh tokens, attempt iframe fallback if configured.
// The iframe may succeed if the user still has a valid session.
if (
(e.message.includes(MISSING_REFRESH_TOKEN_ERROR_MESSAGE) ||
e.message.includes(INVALID_REFRESH_TOKEN_ERROR_MESSAGE)) &&
this.options.useRefreshTokensFallback
) {
return await this._getTokenFromIFrame(options);
}
}
throw e;
}
}
/**
* Propagates a rotated refresh token to every cache entry that still holds
* the previous one (all MRRT entries when MRRT is on). Skipped for online
* mode, where ORTs are non-rotating.
*/
private async _propagateRotatedRefreshToken(
previousRefreshToken: string | undefined,
newRefreshToken: string | undefined
): Promise<void> {
if (this.onlineAccess || !newRefreshToken || !previousRefreshToken) {
return;
}
await this.cacheManager.updateEntry(
previousRefreshToken,
newRefreshToken,
this.options.clientId,
this.options.useMrrt
);
}
private async _saveEntryInCache(
entry: CacheEntry & { id_token: string; decodedToken: DecodedToken }
) {
const { session_expiry, iat } = entry.decodedToken.claims;
if (session_expiry !== undefined) {
if (typeof session_expiry !== 'number') {
throw new GenericError(
'invalid_token',
'Invalid session_expiry: value must be a number.'
);
}
if (session_expiry >= 10_000_000_000) {
throw new GenericError(
'invalid_token',
'Invalid session_expiry: value appears to be in milliseconds; expected a Unix timestamp in seconds.'
);
}
if (iat === undefined || session_expiry <= iat) {
throw new GenericError(
'invalid_token',
'Invalid session_expiry: session ceiling is before or at the token issue time.'
);
}
}
const { id_token, decodedToken, ...entryWithoutIdToken } = entry;
this.userCache.set(CACHE_KEY_ID_TOKEN_SUFFIX, {
id_token,
decodedToken
});
await this.cacheManager.setIdToken(
this.options.clientId,
entry.id_token,
entry.decodedToken
);
await this.cacheManager.set(entryWithoutIdToken);
}
private async _clearLocalSession(clientId: string | null | undefined = this.options.clientId) {
if (clientId === null) {
await this.cacheManager.clear();
} else {
await this.cacheManager.clear(clientId);
}
this.cookieStorage.remove(this.orgHintCookieName, {
cookieDomain: this.options.cookieDomain
});
this.cookieStorage.remove(this.isAuthenticatedCookieName, {
cookieDomain: this.options.cookieDomain
});
this.userCache.remove(CACHE_KEY_ID_TOKEN_SUFFIX);
try {
await this.dpop?.clear();
} catch {
// DPoP storage cleanup is best-effort.
}
if (this.worker) {
try {
await sendMessage({ type: 'clear' }, this.worker);
} catch {
// Worker cleanup is best-effort.
}
}
}
private async _isSessionCeilingReached(): Promise<boolean> {
const inMemory = this.userCache.get<IdTokenEntry>(
CACHE_KEY_ID_TOKEN_SUFFIX
) as IdTokenEntry;
const idTokenEntry =
inMemory ??
await this.cacheManager.getIdToken(
new CacheKey({ clientId: this.options.clientId })
);
const sessionExpiresAt = idTokenEntry?.decodedToken?.claims?.session_expiry;
if (sessionExpiresAt === undefined) return false;
const now = await this.nowProvider();
const nowSeconds = Math.floor(now / 1000);
if (nowSeconds >= sessionExpiresAt - SESSION_EXPIRY_LEEWAY_SECONDS) {
await this._clearLocalSession();
return true;
}
return false;
}
private async _getIdTokenFromCache() {
const audience = this.options.authorizationParams.audience || DEFAULT_AUDIENCE;
const scope = this.scope[audience];
const cache = await this.cacheManager.getIdToken(
new CacheKey({
clientId: this.options.clientId,
audience,
scope,
})
);
const currentCache = this.userCache.get<IdTokenEntry>(
CACHE_KEY_ID_TOKEN_SUFFIX
) as IdTokenEntry;
// If the id_token in the cache matches the value we previously cached in memory return the in-memory
// value so that object comparison will work
if (cache && cache.id_token === currentCache?.id_token) {
return currentCache;
}
this.userCache.set(CACHE_KEY_ID_TOKEN_SUFFIX, cache);
return cache;
}
private async _getEntryFromCache({
scope,
audience,
clientId,
cacheMode,
}: {
scope: string;
audience: string;
clientId: string;
cacheMode?: string;
}): Promise<undefined | GetTokenSilentlyVerboseResponse> {
const entry = await this.cacheManager.get(
new CacheKey({
scope,
audience,
clientId
}),
60, // get a new token if within 60 seconds of expiring
this.options.useMrrt,
cacheMode,
);
if (entry && entry.access_token) {
const { token_type, access_token, oauthTokenScope, expires_in } =
entry as CacheEntry;
const cache = await this._getIdTokenFromCache();
return (
cache && {
id_token: cache.id_token,
token_type: token_type ? token_type : 'Bearer',
access_token,
...(oauthTokenScope ? { scope: oauthTokenScope } : null),
expires_in
}
);
}
}
// Central MFA context store — called from _requestToken for all non-authorization_code
// grants (including via _requestTokenForMfa) and from customTokenExchange.
private _storeMfaContext(e: unknown, scope?: string, audience?: string): void {
if (e instanceof MfaRequiredError) {
this.mfa.setMFAAuthDetails(e.mfa_token, scope, audience, e.mfa_requirements);
}
}
private async _requestToken(
options:
| PKCERequestTokenOptions
| RefreshTokenRequestTokenOptions
| TokenExchangeRequestOptions
| WebauthnRequestTokenOptions,
additionalParameters?: RequestTokenAdditionalParameters
) {
const { nonceIn, organization, scopesToRequest } = additionalParameters || {};
try {
const authResult = await oauthToken(
{
baseUrl: this.domainUrl,
client_id: this.options.clientId,
auth0Client: this.options.auth0Client,
useFormData: this.options.useFormData,
timeout: this.httpTimeoutMs,
useMrrt: this.options.useMrrt,
dpop: this.dpop,
preserveRefreshToken: this.onlineAccess,
...options,
scope: scopesToRequest || options.scope,
},
this.worker
);
let decodedToken = await this._verifyIdToken(
authResult.id_token,
nonceIn,
organization
);
// When logging in with authorization_code, check if a different user is authenticating
// If so, clear the cache to prevent tokens from multiple users coexisting
if (options.grant_type === 'authorization_code') {
const existingIdToken = await this._getIdTokenFromCache();
if (existingIdToken?.decodedToken?.claims?.sub &&
existingIdToken.decodedToken.claims.sub !== decodedToken.claims.sub) {
// Different user detected - clear cached tokens
await this.cacheManager.clear(this.options.clientId);
this.userCache.remove(CACHE_KEY_ID_TOKEN_SUFFIX);
}
}
// Silent refresh — pin the session_expiry ceiling from the initial login.
// Prevents the server from extending the ceiling via a refresh token response.
if (options.grant_type !== 'authorization_code') {
const existingIdToken = await this._getIdTokenFromCache();
const existingCeiling = existingIdToken?.decodedToken?.claims?.session_expiry;
if (existingCeiling !== undefined) {
decodedToken = {
...decodedToken,
claims: { ...decodedToken.claims, session_expiry: existingCeiling }
};
}
}
// Online refresh tokens are non-rotating: the server returns no refresh_token, so
// carry the ORT forward to avoid evicting it on save.
// Online-only: in offline mode a refresh token is single-use and reusing it would
// trip reuse detection.
// Mutate authResult so ...authResult in _saveEntryInCache picks up the ORT directly.
// Safe: GetTokenSilentlyVerboseResponse omits refresh_token, so it never reaches app code.
if (!authResult.refresh_token && this.onlineAccess) {
authResult.refresh_token = (options as RefreshTokenRequestTokenOptions).refresh_token ?? (await this.cacheManager.get(
new CacheKey({
scope: scopesToRequest || options.scope,
audience: options.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId,
}),
undefined,
this.options.useMrrt
))?.refresh_token;
}
await this._saveEntryInCache({
...authResult,
decodedToken,
scope: options.scope,
audience: options.audience || DEFAULT_AUDIENCE,
...(authResult.scope ? { oauthTokenScope: authResult.scope } : null),
client_id: this.options.clientId
});
this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
daysUntilExpire: this.sessionCheckExpiryDays,
cookieDomain: this.options.cookieDomain
});
this._processOrgHint(organization || decodedToken.claims.org_id);
return { ...authResult, decodedToken };
} catch (e) {
// Exclude authorization_code: MFA is handled by Universal Login during the
// authorization phase for redirect/popup flows, not via a token endpoint error.
if (options.grant_type !== 'authorization_code') {
this._storeMfaContext(e, scopesToRequest || options.scope, options.audience);
}
throw e;
}
}
/*
Custom Token Exchange
* **Implementation Notes:**
* - Ensure that the `subject_token` provided has been securely obtained and is valid according
* to your external identity provider's policies before invoking this function.
* - The function leverages internal helper methods:
* - `validateTokenType` confirms that the `subject_token_type` is supported.
* - `getUniqueScopes` merges and de-duplicates scopes between the provided options and
* the instance's default scopes.
* - `_requestToken` performs the actual HTTP request to the token endpoint.
*/
/**
* ```js
* await auth0.loginWithCustomTokenExchange(options);
* ```
*
* Exchanges an external subject token for Auth0 tokens and logs the user in.
* This method implements the Custom Token Exchange grant as specified in RFC 8693.
*
* The exchanged tokens are automatically cached, establishing an authenticated session.
* After calling this method, you can use `getUser()`, `getIdTokenClaims()`, and
* `getTokenSilently()` to access the user's information and tokens.
*
* @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
*
* @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response,
* which contains the issued Auth0 tokens (access_token, id_token, etc.).
*
* The request includes the following parameters:
* - `grant_type`: "urn:ietf:params:oauth:grant-type:token-exchange"
* - `subject_token`: The external token to exchange
* - `subject_token_type`: The type identifier of the external token
* - `scope`: Merged scopes from the request and SDK defaults
* - `audience`: Target audience (defaults to SDK configuration)
* - `organization`: Optional organization ID/name for org-scoped authentication
*
* **Example Usage:**
*
* ```js
* const options = {
* subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
* subject_token_type: 'urn:acme:legacy-system-token',
* scope: 'openid profile email',
* audience: 'https://api.example.com',
* organization: 'org_12345'
* };
*
* try {
* const tokenResponse = await auth0.loginWithCustomTokenExchange(options);
* console.log('Access token:', tokenResponse.access_token);
*
* // User is now logged in - access user info
* const user = await auth0.getUser();
* console.log('Logged in user:', user);
* } catch (error) {
* console.error('Token exchange failed:', error);
* }
* ```
*/
private _buildTokenExchangeParams(
options: CustomTokenExchangeOptions
): TokenExchangeRequestOptions {
return {
...options,
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: options.subject_token,
subject_token_type: options.subject_token_type,
...(options.actor_token && { actor_token: options.actor_token }),
...(options.actor_token_type && { actor_token_type: options.actor_token_type }),
scope: scopesToRequest(
this.scope,
options.scope,
options.audience || this.options.authorizationParams.audience
),
audience: options.audience || this.options.authorizationParams.audience,
organization: options.organization || this.options.authorizationParams.organization
};
}
async loginWithCustomTokenExchange(
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> {
return this._requestToken(this._buildTokenExchangeParams(options));
}
/**
* ```js
* await auth0.customTokenExchange(options);
* ```
*
* Exchanges an external subject token for Auth0 tokens without affecting the current session.
*
* Unlike `loginWithCustomTokenExchange`, this method has no side effects — it does not cache
* tokens, does not update the authenticated session, and does not affect `isAuthenticated()`
* or `getUser()`. Use this for delegation or impersonation scenarios where you need a token
* for a downstream API but do not want to change who the current user is.
*
* When a Web Worker is configured, the refresh_token is discarded inside the worker and
* never reaches the main thread. When no Web Worker is configured, the raw authorization
* server response is returned — if a refresh_token is present, discard it; this method
* intentionally does not store it.
*
* @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
* @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response.
*
* **Example:**
* ```js
* const tokenResponse = await auth0.customTokenExchange({
* subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
* subject_token_type: 'urn:acme:legacy-system-token',
* actor_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
* actor_token_type: 'https://idp.example.com/token-type/agent',
* audience: 'https://api.example.com',
* });
*
* // Use tokenResponse.access_token to call downstream API
* // Current user session is unchanged
* ```
*/
async customTokenExchange(
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> {
const params = this._buildTokenExchangeParams(options);
try {
const result = await oauthToken(
{
...params,
baseUrl: this.domainUrl,
client_id: this.options.clientId,
auth0Client: this.options.auth0Client,
useFormData: this.options.useFormData,
timeout: this.httpTimeoutMs,
dpop: this.dpop,
},
this.worker,
true // skipTokenStorage — when using a worker, refresh_token is discarded inside it
);
if (result.id_token) {
await this._verifyIdToken(result.id_token, undefined, options.organization);
}
return result;
} catch (e) {
this._storeMfaContext(e, params.scope, params.audience);
throw e;
}
}
/**
* @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
*
* Exchanges an external subject token for Auth0 tokens.
*
* @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
* @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response.
*
* **Example:**
* ```js
* // Instead of:
* const tokens = await auth0.exchangeToken(options);
*
* // Use:
* const tokens = await auth0.loginWithCustomTokenExchange(options);
* ```
*/
async exchangeToken(
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> {
return this.loginWithCustomTokenExchange(options);
}
protected _assertDpop(dpop: Dpop | undefined): asserts dpop is Dpop {
if (!dpop) {
throw new Error('`useDpop` option must be enabled before using DPoP.');
}
}
/**
* Returns the current DPoP nonce used for making requests to Auth0.
*
* It can return `undefined` because when starting fresh it will not
* be populated until after the first response from the server.
*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*
* @param nonce The nonce value.
* @param id The identifier of a nonce: if absent, it will get the nonce
* used for requests to Auth0. Otherwise, it will be used to
* select a specific non-Auth0 nonce.
*/
public getDpopNonce(id?: string): Promise<string | undefined> {
this._assertDpop(this.dpop);
return this.dpop.getNonce(id);
}
/**
* Sets the current DPoP nonce used for making requests to Auth0.
*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*
* @param nonce The nonce value.
* @param id The identifier of a nonce: if absent, it will set the nonce
* used for requests to Auth0. Otherwise, it will be used to
* select a specific non-Auth0 nonce.
*/
public setDpopNonce(nonce: string, id?: string): Promise<void> {
this._assertDpop(this.dpop);
return this.dpop.setNonce(nonce, id);
}
/**
* Returns a string to be used to demonstrate possession of the private
* key used to cryptographically bind access tokens with DPoP.
*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*/
public generateDpopProof(params: {
url: string;
method: string;
nonce?: string;
accessToken: string;
}): Promise<string> {
this._assertDpop(this.dpop);
return this.dpop.generateProof(params);
}
/**
* Returns a new `Fetcher` class that will contain a `fetchWithAuth()` method.
* This is a drop-in replacement for the Fetch API's `fetch()` method, but will
* handle certain authentication logic for you, like building the proper auth
* headers or managing DPoP nonces and retries automatically.
*
* Check the `EXAMPLES.md` file for a deeper look into this method.
*/
public createFetcher<TOutput extends CustomFetchMinimalOutput = Response>(
config: FetcherConfig<TOutput> = {}
): Fetcher<TOutput> {
return new Fetcher(config, {
isDpopEnabled: () => !!this.options.useDpop,
getAccessToken: authParams =>
this.getTokenSilently({
authorizationParams: {
scope: authParams?.scope?.join(' '),
audience: authParams?.audience
},
detailedResponse: true
}),
getDpopNonce: () => this.getDpopNonce(config.dpopNonceId),
setDpopNonce: nonce => this.setDpopNonce(nonce, config.dpopNonceId),
generateDpopProof: params => this.generateDpopProof(params)
});
}
/**
* Initiates a redirect to connect the user's account with a specified connection.
* This method generates PKCE parameters, creates a transaction, and redirects to the /connect endpoint.
*
* You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts.
*
* @template TAppState - The application state to persist through the transaction.
* @param {RedirectConnectAccountOptions<TAppState>} options - Options for the connect account redirect flow.
* @param {string} options.connection - The name of the connection to link (e.g. 'google-oauth2').
* @param {string[]} [options.scopes] - Array of scopes to request from the Identity Provider during the connect account flow.
* @param {AuthorizationParams} [options.authorization_params] - Additional authorization parameters for the request to the upstream IdP.
* @param {string} [options.redirectUri] - The URI to redirect back to after connecting the account.
* @param {TAppState} [options.appState] - Application state to persist through the transaction.
* @param {(url: string) => Promise<void>} [options.openUrl] - Custom function to open the URL.
*
* @returns {Promise<void>} Resolves when the redirect is initiated.
* @throws {MyAccountApiError} If the connect request to the My Account API fails.
*/
public async connectAccountWithRedirect<TAppState = any>(
options: RedirectConnectAccountOptions<TAppState>
) {
const {
openUrl,
appState,
connection,
scopes,
authorization_params,
redirectUri = this.options.authorizationParams.redirect_uri ||
window.location.origin
} = options;
if (!connection) {
throw new Error('connection is required');
}
const state = encode(createRandomString());
const code_verifier = createRandomString();
const code_challengeBuffer = await sha256(code_verifier);
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
const { connect_uri, connect_params, auth_session } =
await this.myAccount.connectAccount({
connection,
scopes,
redirect_uri: redirectUri,
state,
code_challenge,
code_challenge_method: 'S256',
authorization_params
});
this.transactionManager.create<ConnectAccountTransaction>({
state,
code_verifier,
auth_session,
redirect_uri: redirectUri,
appState,
connection,
response_type: ResponseType.ConnectCode
});
const url = new URL(connect_uri);
url.searchParams.set('ticket', connect_params.ticket);
if (openUrl) {
await openUrl(url.toString());
} else {
window.location.assign(url);
}
}
/**
* @internal
* Internal method used by PasskeyApiClient to exchange passkey credentials for tokens.
* Routes through _requestToken() so tokens are cached, ID token verified, and session established.
*/
async _requestTokenForPasskey(
options: {
authSession: string;
credential: PasskeyCredentialResponse;
realm?: string;
scope?: string;
audience?: string;
organization?: string;
}
): Promise<TokenEndpointResponse> {
const audience = options.audience || this.options.authorizationParams.audience;
const organization = options.organization || this.options.authorizationParams.organization;
return this._requestToken({
grant_type: 'urn:okta:params:oauth:grant-type:webauthn',
auth_session: options.authSession,
authn_response: options.credential,
...(options.realm && { realm: options.realm }),
...(organization && { organization }),
scope: scopesToRequest(this.scope, options.scope, audience),
audience,
});
}
/**
* @internal
* Internal method used by MfaApiClient to exchange MFA tokens for access tokens.
* This method should not be called directly by applications.
*/
async _requestTokenForMfa(
options: {
grant_type: string;
mfaToken: string;
scope?: string;
audience?: string;
otp?: string;
binding_code?: string;
oob_code?: string;
recovery_code?: string;
},
additionalParameters?: RequestTokenAdditionalParameters
): Promise<TokenEndpointResponse> {
// Need to add better typing here
const { mfaToken, ...restOptions } = options;
// The refresh exchange that was refused with mfa_required consumed the refresh
// token some cache entry holds, and the completion grant below rotates it. Read
// that entry first so the rotated token can be propagated to every entry
// afterwards, as _getTokenUsingRefreshToken does; otherwise the other entries
// keep the rotated-away token and their next refresh trips reuse detection.
const previous = await this.cacheManager.get(
new CacheKey({
scope: restOptions.scope,
audience: restOptions.audience || DEFAULT_AUDIENCE,
clientId: this.options.clientId
}),
undefined,
this.options.useMrrt
);
const result = await this._requestToken(
{ ...restOptions, mfa_token: mfaToken } as any,
additionalParameters
);
await this._propagateRotatedRefreshToken(
previous?.refresh_token,
result.refresh_token
);
return result;
}
}
interface BaseRequestTokenOptions {
audience?: string;
scope: string;
timeout?: number;
redirect_uri?: string;
}
interface PKCERequestTokenOptions extends BaseRequestTokenOptions {
code: string;
grant_type: 'authorization_code';
code_verifier: string;
}
interface RefreshTokenRequestTokenOptions extends BaseRequestTokenOptions {
grant_type: 'refresh_token';
refresh_token?: string;
}
interface TokenExchangeRequestOptions extends BaseRequestTokenOptions {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange';
subject_token: string;
subject_token_type: string;
actor_token?: string;
actor_token_type?: string;
organization?: string;
}
interface WebauthnRequestTokenOptions extends BaseRequestTokenOptions {
grant_type: 'urn:okta:params:oauth:grant-type:webauthn';
auth_session: string;
authn_response: PasskeyCredentialResponse;
realm?: string;
organization?: string;
}
interface RequestTokenAdditionalParameters {
nonceIn?: string;
organization?: string;
scopesToRequest?: string;
}