@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
text/typescript
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