UNPKG

@auth0/auth0-spa-js

Version:

Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE

1,594 lines (1,417 loc) 51.3 kB
import Lock from 'browser-tabs-lock'; import { createQueryParams, runPopup, parseAuthenticationResult, encode, createRandomString, runIframe, sha256, bufferToBase64UrlEncoded, validateCrypto, openPopup, getDomain, getTokenIssuer, parseNumber } from './utils'; import { oauthToken } 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, 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, DEFAULT_SCOPE, DEFAULT_SESSION_CHECK_EXPIRY_DAYS, DEFAULT_AUTH0_CLIENT, INVALID_REFRESH_TOKEN_ERROR_MESSAGE, DEFAULT_NOW_PROVIDER, DEFAULT_FETCH_TIMEOUT_MS, DEFAULT_AUDIENCE } from './constants'; import { Auth0ClientOptions, AuthorizationParams, AuthorizeOptions, RedirectLoginOptions, PopupLoginOptions, PopupConfigOptions, RedirectLoginResult, GetTokenSilentlyOptions, GetTokenWithPopupOptions, LogoutOptions, CacheLocation, LogoutUrlOptions, User, IdToken, GetTokenSilentlyVerboseResponse, TokenEndpointResponse, AuthenticationResult, ConnectAccountRedirectResult, RedirectConnectAccountOptions, ResponseType, ClientAuthorizationParams, } from './global'; // @ts-ignore import TokenWorker from './worker/token.worker.ts'; import { singlePromise, retryPromise } from './promise-utils'; import { CacheKeyManifest } from './cache/key-manifest'; import { buildIsAuthenticatedCookieName, buildOrganizationHintCookieName, cacheFactory, getAuthorizeParams, buildGetTokenSilentlyLockKey, OLD_IS_AUTHENTICATED_COOKIE_NAME, patchOpenUrlWithOnRedirect, getScopeToRequest, allScopesAreIncluded, isRefreshWithMrrt, getMissingScopes } from './Auth0Client.utils'; import { CustomTokenExchangeOptions } from './TokenExchange'; import { Dpop } from './dpop/dpop'; import { Fetcher, type FetcherConfig, type CustomFetchMinimalOutput } from './fetcher'; import { MyAccountApiClient } from './MyAccountApiClient'; /** * @ignore */ type GetTokenSilentlyResult = TokenEndpointResponse & { decodedToken: ReturnType<typeof verifyIdToken>; scope: string; oauthTokenScope?: string; audience: string; }; /** * @ignore */ const lock = new Lock(); /** * 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 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 options: Auth0ClientOptions & { authorizationParams: ClientAuthorizationParams, }; private readonly userCache: ICache = new InMemoryCache().enclosedCache; private readonly myAccountApi: MyAccountApiClient; private worker?: Worker; private readonly activeLockKeys: Set<string> = new Set(); private readonly defaultOptions: Partial<Auth0ClientOptions> = { authorizationParams: { scope: DEFAULT_SCOPE }, useRefreshTokensFallback: false, useFormData: true }; constructor(options: Auth0ClientOptions) { this.options = { ...this.defaultOptions, ...options, authorizationParams: { ...this.defaultOptions.authorizationParams, ...options.authorizationParams } }; typeof window !== 'undefined' && validateCrypto(); 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; // Construct the scopes based on the following: // 1. Always include `openid` // 2. Include the scopes provided in `authorizationParams. This defaults to `profile email` // 3. Add `offline_access` if `useRefreshTokens` is enabled this.scope = injectDefaultScopes( this.options.authorizationParams.scope, 'openid', this.options.useRefreshTokens ? 'offline_access' : '' ); 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: () => this.getTokenSilently({ authorizationParams: { scope: 'create:me:connected_accounts', audience: myAccountApiIdentifier }, detailedResponse: true }) }); this.myAccountApi = new MyAccountApiClient( myAccountFetcher, myAccountApiIdentifier ); // 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(); } } } private _url(path: string) { const auth0Client = encodeURIComponent( btoa(JSON.stringify(this.options.auth0Client || DEFAULT_AUTH0_CLIENT)) ); 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 }); } } 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 params = await this._prepareAuthorizeUrl( options.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 }); 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> { 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> { 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 { url, ...transaction } = await this._prepareAuthorizeUrl( urlOptions.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.myAccountApi.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 */ public async checkSession(options?: GetTokenSilentlyOptions) { if (!this.cookieStorage.get(this.isAuthenticatedCookieName)) { if (!this.cookieStorage.get(OLD_IS_AUTHENTICATED_COOKIE_NAME)) { 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 (_) { } } /** * 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>; /** * Fetches a new access token and returns it. * * @param options */ public async getTokenSilently( options?: GetTokenSilentlyOptions ): Promise<string>; /** * 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 singlePromise( () => this._getTokenSilently(localOptions), `${this.options.clientId}::${localOptions.authorizationParams.audience}::${localOptions.authorizationParams.scope}` ); return options.detailedResponse ? result : result?.access_token; } private async _getTokenSilently( options: GetTokenSilentlyOptions & { authorizationParams: AuthorizationParams & { scope: string }; } ): Promise<undefined | GetTokenSilentlyVerboseResponse> { const { cacheMode, ...getTokenOptions } = options; // 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' ); if (await retryPromise(() => lock.acquireLock(lockKey, 5000), 10)) { this.activeLockKeys.add(lockKey); // Add event listener only if this is the first active lock if (this.activeLockKeys.size === 1) { window.addEventListener('pagehide', this._releaseLockOnPageHide); } try { // 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 }; } finally { await lock.releaseLock(lockKey); this.activeLockKeys.delete(lockKey); // If we have no more locks, we can remove the event listener to clean up if (this.activeLockKeys.size === 0) { window.removeEventListener('pagehide', this._releaseLockOnPageHide); } } } else { throw new TimeoutError(); } } /** * ```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.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> { const { openUrl, ...logoutOptions } = patchOpenUrlWithOnRedirect(options); if (options.clientId === null) { await this.cacheManager.clear(); } else { await this.cacheManager.clear(options.clientId || this.options.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); await this.dpop?.clear(); 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 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 ); try { // 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') { 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, } ); // If is refreshed with MRRT, we update all entries that have the old // refresh_token with the new one if the server responded with one if (tokenResult.refresh_token && this.options.useMrrt && cache?.refresh_token) { await this.cacheManager.updateEntry( 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 tokenHasAllScopes = allScopesAreIncluded( scopesToRequest, tokenResult.scope, ); if (!tokenHasAllScopes) { 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, ); const missingScopes = getMissingScopes( scopesToRequest, tokenResult.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 ( // The web worker didn't have a refresh token in memory so // fallback to an iframe. (e.message.indexOf(MISSING_REFRESH_TOKEN_ERROR_MESSAGE) > -1 || // A refresh token was found, but is it no longer valid // and useRefreshTokensFallback is explicitly enabled. Fallback to an iframe. (e.message && e.message.indexOf(INVALID_REFRESH_TOKEN_ERROR_MESSAGE) > -1)) && this.options.useRefreshTokensFallback ) { return await this._getTokenFromIFrame(options); } throw e; } } private async _saveEntryInCache( entry: CacheEntry & { id_token: string; decodedToken: DecodedToken } ) { 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 _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 } ); } } /** * Releases any locks acquired by the current page that are not released yet * * Get's called on the `pagehide` event. * https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event */ private _releaseLockOnPageHide = async () => { // Release all active locks const lockKeysToRelease = Array.from(this.activeLockKeys); for (const lockKey of lockKeysToRelease) { await lock.releaseLock(lockKey); } this.activeLockKeys.clear(); window.removeEventListener('pagehide', this._releaseLockOnPageHide); }; private async _requestToken( options: | PKCERequestTokenOptions | RefreshTokenRequestTokenOptions | TokenExchangeRequestOptions, additionalParameters?: RequestTokenAdditionalParameters ) { const { nonceIn, organization, scopesToRequest } = additionalParameters || {}; 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, ...options, scope: scopesToRequest || options.scope, }, this.worker ); const decodedToken = await this._verifyIdToken( authResult.id_token, nonceIn, organization ); 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 }; } /* 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. */ /** * Exchanges an external subject token for an Auth0 token via a token exchange request. * * @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. * * This method implements the token exchange grant as specified in RFC 8693 by first validating * the provided subject token type and then constructing a token request to the /oauth/token endpoint. * The request includes the following parameters: * * - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange". * - `subject_token`: The external token provided via the options. * - `subject_token_type`: The type of the external token (validated by this function). * - `scope`: A unique set of scopes, generated by merging the scopes supplied in the options * with the SDK’s default scopes. * - `audience`: The target audience from the options, with fallback to the SDK's authorization configuration. * * **Example Usage:** * * ``` * // Define the token exchange options * const options: CustomTokenExchangeOptions = { * subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...', * subject_token_type: 'urn:acme:legacy-system-token', * scope: "openid profile" * }; * * // Exchange the external token for Auth0 tokens * try { * const tokenResponse = await instance.exchangeToken(options); * // Use tokenResponse.access_token, tokenResponse.id_token, etc. * } catch (error) { * // Handle token exchange error * } * ``` */ async exchangeToken( options: CustomTokenExchangeOptions ): Promise<TokenEndpointResponse> { return this._requestToken({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: options.subject_token, subject_token_type: options.subject_token_type, scope: scopesToRequest( this.scope, options.scope, options.audience || this.options.authorizationParams.audience ), audience: options.audience || this.options.authorizationParams.audience }); } 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.myAccountApi.connectAccount({ connection, scopes, redirect_uri: redirectUri, state, code_challenge, code_challenge_method: 'S256', autho