UNPKG

@omneedia/auth-js

Version:
1,358 lines (1,213 loc) 42.1 kB
import GoTrueAdminApi from './GoTrueAdminApi' import { DEFAULT_HEADERS, EXPIRY_MARGIN, GOTRUE_URL, NETWORK_FAILURE, STORAGE_KEY, } from './lib/constants' import { AuthError, AuthImplicitGrantRedirectError, AuthInvalidCredentialsError, AuthRetryableFetchError, AuthSessionMissingError, AuthUnknownError, isAuthApiError, isAuthError, } from './lib/errors' import { Fetch, _request, _sessionResponse, _userResponse, _ssoResponse } from './lib/fetch' import { decodeJWTPayload, Deferred, getItemAsync, getParameterByName, isBrowser, removeItemAsync, resolveFetch, setItemAsync, uuid, } from './lib/helpers' import localStorageAdapter from './lib/local-storage' import { polyfillGlobalThis } from './lib/polyfills' import type { AuthChangeEvent, AuthResponse, CallRefreshTokenResult, GoTrueClientOptions, InitializeResult, OAuthResponse, SSOResponse, Provider, Session, SignInWithOAuthCredentials, SignInWithPasswordCredentials, SignInWithPasswordlessCredentials, SignInWithSSO, SignUpWithPasswordCredentials, Subscription, SupportedStorage, User, UserAttributes, UserResponse, VerifyOtpParams, GoTrueMFAApi, MFAEnrollParams, AuthMFAEnrollResponse, MFAChallengeParams, AuthMFAChallengeResponse, MFAUnenrollParams, AuthMFAUnenrollResponse, MFAVerifyParams, AuthMFAVerifyResponse, AuthMFAListFactorsResponse, AMREntry, AuthMFAGetAuthenticatorAssuranceLevelResponse, AuthenticatorAssuranceLevels, Factor, MFAChallengeAndVerifyParams, } from './lib/types' polyfillGlobalThis() // Make "globalThis" available const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage'> = { url: GOTRUE_URL, storageKey: STORAGE_KEY, autoRefreshToken: true, persistSession: true, detectSessionInUrl: true, headers: DEFAULT_HEADERS, } export default class GoTrueClient { /** * Namespace for the GoTrue admin methods. * These methods should only be used in a trusted server-side environment. */ admin: GoTrueAdminApi /** * Namespace for the MFA methods. */ mfa: GoTrueMFAApi /** * The storage key used to identify the values saved in localStorage */ protected storageKey: string /** * The session object for the currently logged in user. If null, it means there isn't a logged-in user. * Only used if persistSession is false. */ protected inMemorySession: Session | null protected autoRefreshToken: boolean protected persistSession: boolean protected storage: SupportedStorage protected stateChangeEmitters: Map<string, Subscription> = new Map() protected refreshTokenTimer?: ReturnType<typeof setTimeout> protected networkRetries = 0 protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null /** * Keeps track of the async client initialization. * When null or not yet resolved the auth state is `unknown` * Once resolved the the auth state is known and it's save to call any further client methods. * Keep extra care to never reject or throw uncaught errors */ protected initializePromise: Promise<InitializeResult> | null = null protected detectSessionInUrl = true protected url: string protected headers: { [key: string]: string } protected fetch: Fetch /** * Create a new client for use in the browser. */ constructor(options: GoTrueClientOptions) { const settings = { ...DEFAULT_OPTIONS, ...options } this.inMemorySession = null this.storageKey = settings.storageKey this.autoRefreshToken = settings.autoRefreshToken this.persistSession = settings.persistSession this.storage = settings.storage || localStorageAdapter this.admin = new GoTrueAdminApi({ url: settings.url, headers: settings.headers, fetch: settings.fetch, }) this.url = settings.url this.headers = settings.headers this.fetch = resolveFetch(settings.fetch) this.detectSessionInUrl = settings.detectSessionInUrl this.initialize() this.mfa = { verify: this._verify.bind(this), enroll: this._enroll.bind(this), unenroll: this._unenroll.bind(this), challenge: this._challenge.bind(this), listFactors: this._listFactors.bind(this), challengeAndVerify: this._challengeAndVerify.bind(this), getAuthenticatorAssuranceLevel: this._getAuthenticatorAssuranceLevel.bind(this), } } /** * Initializes the client session either from the url or from storage. * This method is automatically called when instantiating the client, but should also be called * manually when checking for an error from an auth redirect (oauth, magiclink, password recovery, etc). */ initialize(): Promise<InitializeResult> { if (!this.initializePromise) { this.initializePromise = this._initialize() } return this.initializePromise } /** * IMPORTANT: * 1. Never throw in this method, as it is called from the constructor * 2. Never return a session from this method as it would be cached over * the whole lifetime of the client */ private async _initialize(): Promise<InitializeResult> { if (this.initializePromise) { return this.initializePromise } try { if (this.detectSessionInUrl && this._isImplicitGrantFlow()) { const { data, error } = await this._getSessionFromUrl() if (error) { // failed login attempt via url, // remove old session as in verifyOtp, signUp and signInWith* await this._removeSession() return { error } } const { session, redirectType } = data await this._saveSession(session) this._notifyAllSubscribers('SIGNED_IN', session) if (redirectType === 'recovery') { this._notifyAllSubscribers('PASSWORD_RECOVERY', session) } return { error: null } } // no login attempt via callback url try to recover session from storage await this._recoverAndRefresh() return { error: null } } catch (error) { if (isAuthError(error)) { return { error } } return { error: new AuthUnknownError('Unexpected error during initialization', error), } } finally { this._handleVisibilityChange() } } /** * Creates a new user. * @returns A logged-in session if the server has "autoconfirm" ON * @returns A user if the server has "autoconfirm" OFF */ async signUp(credentials: SignUpWithPasswordCredentials): Promise<AuthResponse> { try { await this._removeSession() let res: AuthResponse if ('email' in credentials) { const { email, password, options } = credentials res = await _request(this.fetch, 'POST', `${this.url}/signup`, { headers: this.headers, redirectTo: options?.emailRedirectTo, body: { email, password, data: options?.data ?? {}, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, xform: _sessionResponse, }) } else if ('phone' in credentials) { const { phone, password, options } = credentials res = await _request(this.fetch, 'POST', `${this.url}/signup`, { headers: this.headers, body: { phone, password, data: options?.data ?? {}, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, xform: _sessionResponse, }) } else { throw new AuthInvalidCredentialsError( 'You must provide either an email or phone number and a password' ) } const { data, error } = res if (error || !data) { return { data: { user: null, session: null }, error: error } } const session: Session | null = data.session const user: User | null = data.user if (data.session) { await this._saveSession(data.session) this._notifyAllSubscribers('SIGNED_IN', session) } return { data: { user, session }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { user: null, session: null }, error } } throw error } } /** * Log in an existing user with an email and password or phone and password. */ async signInWithPassword(credentials: SignInWithPasswordCredentials): Promise<AuthResponse> { try { await this._removeSession() let res: AuthResponse if ('email' in credentials) { const { email, password, options } = credentials res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, { headers: this.headers, body: { email, password, data: options?.data ?? {}, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, xform: _sessionResponse, }) } else if ('phone' in credentials) { const { phone, password, options } = credentials res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, { headers: this.headers, body: { phone, password, data: options?.data ?? {}, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, xform: _sessionResponse, }) } else { throw new AuthInvalidCredentialsError( 'You must provide either an email or phone number and a password' ) } const { data, error } = res if (error || !data) return { data: { user: null, session: null }, error } if (data.session) { await this._saveSession(data.session) this._notifyAllSubscribers('SIGNED_IN', data.session) } return { data, error } } catch (error) { if (isAuthError(error)) { return { data: { user: null, session: null }, error } } throw error } } /** * Log in an existing user via a third-party provider. */ async signInWithOAuth(credentials: SignInWithOAuthCredentials): Promise<OAuthResponse> { await this._removeSession() return this._handleProviderSignIn(credentials.provider, { redirectTo: credentials.options?.redirectTo, scopes: credentials.options?.scopes, queryParams: credentials.options?.queryParams, }) } /** * Log in a user using magiclink or a one-time password (OTP). * If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. * If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent. * If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins. */ async signInWithOtp(credentials: SignInWithPasswordlessCredentials): Promise<AuthResponse> { try { await this._removeSession() if ('email' in credentials) { const { email, options } = credentials const { error } = await _request(this.fetch, 'POST', `${this.url}/otp`, { headers: this.headers, body: { email, data: options?.data ?? {}, create_user: options?.shouldCreateUser ?? true, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, redirectTo: options?.emailRedirectTo, }) return { data: { user: null, session: null }, error } } if ('phone' in credentials) { const { phone, options } = credentials const { error } = await _request(this.fetch, 'POST', `${this.url}/otp`, { headers: this.headers, body: { phone, data: options?.data ?? {}, create_user: options?.shouldCreateUser ?? true, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, }) return { data: { user: null, session: null }, error } } throw new AuthInvalidCredentialsError('You must provide either an email or phone number.') } catch (error) { if (isAuthError(error)) { return { data: { user: null, session: null }, error } } throw error } } /** * Log in a user given a User supplied OTP received via mobile. */ async verifyOtp(params: VerifyOtpParams): Promise<AuthResponse> { try { await this._removeSession() const { data, error } = await _request(this.fetch, 'POST', `${this.url}/verify`, { headers: this.headers, body: { ...params, gotrue_meta_security: { captcha_token: params.options?.captchaToken }, }, redirectTo: params.options?.redirectTo, xform: _sessionResponse, }) if (error) { throw error } if (!data) { throw 'An error occurred on token verification.' } const session: Session | null = data.session const user: User = data.user if (session?.access_token) { await this._saveSession(session as Session) this._notifyAllSubscribers('SIGNED_IN', session) } return { data: { user, session }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { user: null, session: null }, error } } throw error } } /** * Attempts a single-sign on using an enterprise Identity Provider. A * successful SSO attempt will redirect the current page to the identity * provider authorization page. The redirect URL is implementation and SSO * protocol specific. * * You can use it by providing a SSO domain. Typically you can extract this * domain by asking users for their email address. If this domain is * registered on the Auth instance the redirect will use that organization's * currently active SSO Identity Provider for the login. * * If you have built an organization-specific login page, you can use the * organization's SSO Identity Provider UUID directly instead. * * This API is experimental and availability is conditional on correct * settings on the Auth service. * * @experimental */ async signInWithSSO(params: SignInWithSSO): Promise<SSOResponse> { try { await this._removeSession() return await _request(this.fetch, 'POST', `${this.url}/sso`, { body: { ...('providerId' in params ? { provider_id: params.providerId } : null), ...('domain' in params ? { domain: params.domain } : null), redirect_to: params.options?.redirectTo ?? undefined, ...(params?.options?.captchaToken ? { gotrue_meta_security: { captcha_token: params.options.captchaToken } } : null), skip_http_redirect: true, // fetch does not handle redirects }, headers: this.headers, xform: _ssoResponse, }) } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Returns the session, refreshing it if necessary. * The session returned can be null if the session is not detected which can happen in the event a user is not signed-in or has logged out. */ async getSession(): Promise< | { data: { session: Session } error: null } | { data: { session: null } error: AuthError } | { data: { session: null } error: null } > { // make sure we've read the session from the url if there is one // save to just await, as long we make sure _initialize() never throws await this.initializePromise let currentSession: Session | null = null if (this.persistSession) { const maybeSession = await getItemAsync(this.storage, this.storageKey) if (maybeSession !== null) { if (this._isValidSession(maybeSession)) { currentSession = maybeSession } else { await this._removeSession() } } } else { currentSession = this.inMemorySession } if (!currentSession) { return { data: { session: null }, error: null } } const hasExpired = currentSession.expires_at ? currentSession.expires_at <= Date.now() / 1000 : false if (!hasExpired) { return { data: { session: currentSession }, error: null } } const { session, error } = await this._callRefreshToken(currentSession.refresh_token) if (error) { return { data: { session: null }, error } } return { data: { session }, error: null } } /** * Gets the current user details if there is an existing session. * @param jwt Takes in an optional access token jwt. If no jwt is provided, getUser() will attempt to get the jwt from the current session. */ async getUser(jwt?: string): Promise<UserResponse> { try { if (!jwt) { const { data, error } = await this.getSession() if (error) { throw error } // Default to Authorization header if there is no existing session jwt = data.session?.access_token ?? undefined } return await _request(this.fetch, 'GET', `${this.url}/user`, { headers: this.headers, jwt: jwt, xform: _userResponse, }) } catch (error) { if (isAuthError(error)) { return { data: { user: null }, error } } throw error } } /** * Updates user data, if there is a logged in user. */ async updateUser(attributes: UserAttributes): Promise<UserResponse> { try { const { data: sessionData, error: sessionError } = await this.getSession() if (sessionError) { throw sessionError } if (!sessionData.session) { throw new AuthSessionMissingError() } const session: Session = sessionData.session const { data, error: userError } = await _request(this.fetch, 'PUT', `${this.url}/user`, { headers: this.headers, body: attributes, jwt: session.access_token, xform: _userResponse, }) if (userError) throw userError session.user = data.user as User await this._saveSession(session) this._notifyAllSubscribers('USER_UPDATED', session) return { data: { user: session.user }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { user: null }, error } } throw error } } /** * Decodes a JWT (without performing any validation). */ private _decodeJWT(jwt: string): { exp?: number aal?: AuthenticatorAssuranceLevels | null amr?: AMREntry[] | null } { return decodeJWTPayload(jwt) } /** * Sets the session data from the current session. If the current session is expired, setSession will take care of refreshing it to obtain a new session. * If the refresh token or access token in the current session is invalid, an error will be thrown. * @param currentSession The current session that minimally contains an access token and refresh token. */ async setSession(currentSession: { access_token: string refresh_token: string }): Promise<AuthResponse> { try { if (!currentSession.access_token || !currentSession.refresh_token) { throw new AuthSessionMissingError() } const timeNow = Date.now() / 1000 let expiresAt = timeNow let hasExpired = true let session: Session | null = null const payload = decodeJWTPayload(currentSession.access_token) if (payload.exp) { expiresAt = payload.exp hasExpired = expiresAt <= timeNow } if (hasExpired) { const { session: refreshedSession, error } = await this._callRefreshToken( currentSession.refresh_token ) if (error) { return { data: { user: null, session: null }, error: error } } if (!refreshedSession) { return { data: { user: null, session: null }, error: null } } session = refreshedSession } else { const { data, error } = await this.getUser(currentSession.access_token) if (error) { throw error } session = { access_token: currentSession.access_token, refresh_token: currentSession.refresh_token, user: data.user, token_type: 'bearer', expires_in: expiresAt - timeNow, expires_at: expiresAt, } await this._saveSession(session) } return { data: { user: session.user, session }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { session: null, user: null }, error } } throw error } } /** * Returns a new session, regardless of expiry status. * Takes in an optional current session. If not passed in, then refreshSession() will attempt to retrieve it from getSession(). * If the current session's refresh token is invalid, an error will be thrown. * @param currentSession The current session. If passed in, it must contain a refresh token. */ async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> { try { if (!currentSession) { const { data, error } = await this.getSession() if (error) { throw error } currentSession = data.session ?? undefined } if (!currentSession?.refresh_token) { throw new AuthSessionMissingError() } const { session, error } = await this._callRefreshToken(currentSession.refresh_token) if (error) { return { data: { user: null, session: null }, error: error } } if (!session) { return { data: { user: null, session: null }, error: null } } return { data: { user: session.user, session }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { user: null, session: null }, error } } throw error } } /** * Gets the session data from a URL string */ private async _getSessionFromUrl(): Promise< | { data: { session: Session; redirectType: string | null } error: null } | { data: { session: null; redirectType: null }; error: AuthError } > { try { if (!isBrowser()) throw new AuthImplicitGrantRedirectError('No browser detected.') if (!this._isImplicitGrantFlow()) { throw new AuthImplicitGrantRedirectError('Not a valid implicit grant flow url.') } const error_description = getParameterByName('error_description') if (error_description) { const error_code = getParameterByName('error_code') if (!error_code) throw new AuthImplicitGrantRedirectError('No error_code detected.') const error = getParameterByName('error') if (!error) throw new AuthImplicitGrantRedirectError('No error detected.') throw new AuthImplicitGrantRedirectError(error_description, { error, code: error_code }) } const provider_token = getParameterByName('provider_token') const provider_refresh_token = getParameterByName('provider_refresh_token') const access_token = getParameterByName('access_token') if (!access_token) throw new AuthImplicitGrantRedirectError('No access_token detected.') const expires_in = getParameterByName('expires_in') if (!expires_in) throw new AuthImplicitGrantRedirectError('No expires_in detected.') const refresh_token = getParameterByName('refresh_token') if (!refresh_token) throw new AuthImplicitGrantRedirectError('No refresh_token detected.') const token_type = getParameterByName('token_type') if (!token_type) throw new AuthImplicitGrantRedirectError('No token_type detected.') const timeNow = Math.round(Date.now() / 1000) const expires_at = timeNow + parseInt(expires_in) const { data, error } = await this.getUser(access_token) if (error) throw error const user: User = data.user const session: Session = { provider_token, provider_refresh_token, access_token, expires_in: parseInt(expires_in), expires_at, refresh_token, token_type, user, } const redirectType = getParameterByName('type') // Remove tokens from URL window.location.hash = '' return { data: { session, redirectType }, error: null } } catch (error) { if (isAuthError(error)) { return { data: { session: null, redirectType: null }, error } } throw error } } /** * Checks if the current URL contains parameters given by an implicit oauth grant flow (https://www.rfc-editor.org/rfc/rfc6749.html#section-4.2) */ private _isImplicitGrantFlow(): boolean { return ( isBrowser() && (Boolean(getParameterByName('access_token')) || Boolean(getParameterByName('error_description'))) ) } /** * Inside a browser context, `signOut()` will remove the logged in user from the browser session * and log them out - removing all items from localstorage and then trigger a `"SIGNED_OUT"` event. * * For server-side management, you can revoke all refresh tokens for a user by passing a user's JWT through to `auth.api.signOut(JWT: string)`. * There is no way to revoke a user's access token jwt until it expires. It is recommended to set a shorter expiry on the jwt for this reason. */ async signOut(): Promise<{ error: AuthError | null }> { const { data, error: sessionError } = await this.getSession() if (sessionError) { return { error: sessionError } } const accessToken = data.session?.access_token if (accessToken) { const { error } = await this.admin.signOut(accessToken) if (error) { // ignore 404s since user might not exist anymore // ignore 401s since an invalid or expired JWT should sign out the current session if (!(isAuthApiError(error) && (error.status === 404 || error.status === 401))) { return { error } } } } await this._removeSession() this._notifyAllSubscribers('SIGNED_OUT', null) return { error: null } } /** * Receive a notification every time an auth event happens. * @param callback A callback function to be invoked when an auth event happens. */ onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => void): { data: { subscription: Subscription } } { const id: string = uuid() const subscription: Subscription = { id, callback, unsubscribe: () => { this.stateChangeEmitters.delete(id) }, } this.stateChangeEmitters.set(id, subscription) return { data: { subscription } } } /** * Sends a password reset request to an email address. * @param email The email address of the user. * @param options.redirectTo The URL to send the user to after they click the password reset link. * @param options.captchaToken Verification token received when the user completes the captcha on the site. */ async resetPasswordForEmail( email: string, options: { redirectTo?: string captchaToken?: string } = {} ): Promise< | { data: {} error: null } | { data: null; error: AuthError } > { try { return await _request(this.fetch, 'POST', `${this.url}/recover`, { body: { email, gotrue_meta_security: { captcha_token: options.captchaToken } }, headers: this.headers, redirectTo: options.redirectTo, }) } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Generates a new JWT. * @param refreshToken A valid refresh token that was returned on login. */ private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> { try { return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, { body: { refresh_token: refreshToken }, headers: this.headers, xform: _sessionResponse, }) } catch (error) { if (isAuthError(error)) { return { data: { session: null, user: null }, error } } throw error } } private _isValidSession(maybeSession: unknown): maybeSession is Session { const isValidSession = typeof maybeSession === 'object' && maybeSession !== null && 'access_token' in maybeSession && 'refresh_token' in maybeSession && 'expires_at' in maybeSession return isValidSession } private _handleProviderSignIn( provider: Provider, options: { redirectTo?: string scopes?: string queryParams?: { [key: string]: string } } = {} ) { const url: string = this._getUrlForProvider(provider, { redirectTo: options.redirectTo, scopes: options.scopes, queryParams: options.queryParams, }) // try to open on the browser if (isBrowser()) { window.location.href = url } return { data: { provider, url }, error: null } } /** * Recovers the session from LocalStorage and refreshes * Note: this method is async to accommodate for AsyncStorage e.g. in React native. */ private async _recoverAndRefresh() { try { const currentSession = await getItemAsync(this.storage, this.storageKey) if (!this._isValidSession(currentSession)) { if (currentSession !== null) { await this._removeSession() } return } const timeNow = Math.round(Date.now() / 1000) if ((currentSession.expires_at ?? Infinity) < timeNow + EXPIRY_MARGIN) { if (this.autoRefreshToken && currentSession.refresh_token) { this.networkRetries++ const { error } = await this._callRefreshToken(currentSession.refresh_token) if (error) { console.log(error.message) if ( error instanceof AuthRetryableFetchError && this.networkRetries < NETWORK_FAILURE.MAX_RETRIES ) { if (this.refreshTokenTimer) clearTimeout(this.refreshTokenTimer) this.refreshTokenTimer = setTimeout( () => this._recoverAndRefresh(), NETWORK_FAILURE.RETRY_INTERVAL ** this.networkRetries * 100 // exponential backoff ) return } await this._removeSession() } this.networkRetries = 0 } else { await this._removeSession() } } else { if (this.persistSession) { await this._saveSession(currentSession) } this._notifyAllSubscribers('SIGNED_IN', currentSession) } } catch (err) { console.error(err) return } } private async _callRefreshToken(refreshToken: string): Promise<CallRefreshTokenResult> { // refreshing is already in progress if (this.refreshingDeferred) { return this.refreshingDeferred.promise } try { this.refreshingDeferred = new Deferred<CallRefreshTokenResult>() if (!refreshToken) { throw new AuthSessionMissingError() } const { data, error } = await this._refreshAccessToken(refreshToken) if (error) throw error if (!data.session) throw new AuthSessionMissingError() await this._saveSession(data.session) this._notifyAllSubscribers('TOKEN_REFRESHED', data.session) const result = { session: data.session, error: null } this.refreshingDeferred.resolve(result) return result } catch (error) { if (isAuthError(error)) { const result = { session: null, error } this.refreshingDeferred?.resolve(result) return result } this.refreshingDeferred?.reject(error) throw error } finally { this.refreshingDeferred = null } } private _notifyAllSubscribers(event: AuthChangeEvent, session: Session | null) { this.stateChangeEmitters.forEach((x) => x.callback(event, session)) } /** * set currentSession and currentUser * process to _startAutoRefreshToken if possible */ private async _saveSession(session: Session) { if (!this.persistSession) { this.inMemorySession = session } const expiresAt = session.expires_at if (expiresAt) { const timeNow = Math.round(Date.now() / 1000) const expiresIn = expiresAt - timeNow const refreshDurationBeforeExpires = expiresIn > EXPIRY_MARGIN ? EXPIRY_MARGIN : 0.5 this._startAutoRefreshToken((expiresIn - refreshDurationBeforeExpires) * 1000) } if (this.persistSession && session.expires_at) { await this._persistSession(session) } } private _persistSession(currentSession: Session) { return setItemAsync(this.storage, this.storageKey, currentSession) } private async _removeSession() { if (this.persistSession) { await removeItemAsync(this.storage, this.storageKey) } else { this.inMemorySession = null } if (this.refreshTokenTimer) { clearTimeout(this.refreshTokenTimer) } } /** * Clear and re-create refresh token timer * @param value time intervals in milliseconds. * @param session The current session. */ private _startAutoRefreshToken(value: number) { if (this.refreshTokenTimer) clearTimeout(this.refreshTokenTimer) if (value <= 0 || !this.autoRefreshToken) return this.refreshTokenTimer = setTimeout(async () => { this.networkRetries++ const { data: { session }, error: sessionError, } = await this.getSession() if (!sessionError && session) { const { error } = await this._callRefreshToken(session.refresh_token) if (!error) this.networkRetries = 0 if ( error instanceof AuthRetryableFetchError && this.networkRetries < NETWORK_FAILURE.MAX_RETRIES ) this._startAutoRefreshToken(NETWORK_FAILURE.RETRY_INTERVAL ** this.networkRetries * 100) // exponential backoff } }, value) if (typeof this.refreshTokenTimer.unref === 'function') this.refreshTokenTimer.unref() } private _handleVisibilityChange() { if (!isBrowser() || !window?.addEventListener) { return false } try { window?.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible') { await this.initializePromise await this._recoverAndRefresh() } }) } catch (error) { console.error('_handleVisibilityChange', error) } } /** * Generates the relevant login URL for a third-party provider. * @param options.redirectTo A URL or mobile address to send the user to after they are confirmed. * @param options.scopes A space-separated list of scopes granted to the OAuth application. * @param options.queryParams An object of key-value pairs containing query parameters granted to the OAuth application. */ private _getUrlForProvider( provider: Provider, options: { redirectTo?: string scopes?: string queryParams?: { [key: string]: string } } ) { const urlParams: string[] = [`provider=${encodeURIComponent(provider)}`] if (options?.redirectTo) { urlParams.push(`redirect_to=${encodeURIComponent(options.redirectTo)}`) } if (options?.scopes) { urlParams.push(`scopes=${encodeURIComponent(options.scopes)}`) } if (options?.queryParams) { const query = new URLSearchParams(options.queryParams) urlParams.push(query.toString()) } return `${this.url}/authorize?${urlParams.join('&')}` } private async _unenroll(params: MFAUnenrollParams): Promise<AuthMFAUnenrollResponse> { try { const { data: sessionData, error: sessionError } = await this.getSession() if (sessionError) { return { data: null, error: sessionError } } return await _request(this.fetch, 'DELETE', `${this.url}/factors/${params.factorId}`, { headers: this.headers, jwt: sessionData?.session?.access_token, }) } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Enrolls a factor * @param friendlyName Human readable name assigned to a device * @param factorType device which we're validating against. Can only be TOTP for now. * @param issuer domain which the user is enrolling with */ private async _enroll(params: MFAEnrollParams): Promise<AuthMFAEnrollResponse> { try { const { data: sessionData, error: sessionError } = await this.getSession() if (sessionError) { return { data: null, error: sessionError } } const { data, error } = await _request(this.fetch, 'POST', `${this.url}/factors`, { body: { friendly_name: params.friendlyName, factor_type: params.factorType, issuer: params.issuer, }, headers: this.headers, jwt: sessionData?.session?.access_token, }) if (error) { return { data: null, error } } if (data?.totp?.qr_code) { data.totp.qr_code = `data:image/svg+xml;utf-8,${data.totp.qr_code}` } return { data, error: null } } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Validates a device as part of the enrollment step. * @param factorId System assigned identifier for authenticator device as returned by enroll * @param code Code Generated by an authenticator device */ private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> { try { const { data: sessionData, error: sessionError } = await this.getSession() if (sessionError) { return { data: null, error: sessionError } } const { data, error } = await _request( this.fetch, 'POST', `${this.url}/factors/${params.factorId}/verify`, { body: { code: params.code, challenge_id: params.challengeId }, headers: this.headers, jwt: sessionData?.session?.access_token, } ) if (error) { return { data: null, error } } await this._saveSession({ expires_at: Math.round(Date.now() / 1000) + data.expires_in, ...data, }) this._notifyAllSubscribers('MFA_CHALLENGE_VERIFIED', data) return { data, error } } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Creates a challenge which a user can verify against * @param factorId System assigned identifier for authenticator device as returned by enroll */ private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> { try { const { data: sessionData, error: sessionError } = await this.getSession() if (sessionError) { return { data: null, error: sessionError } } return await _request( this.fetch, 'POST', `${this.url}/factors/${params.factorId}/challenge`, { headers: this.headers, jwt: sessionData?.session?.access_token, } ) } catch (error) { if (isAuthError(error)) { return { data: null, error } } throw error } } /** * Creates a challenge and immediately verifies it * @param factorId System assigned identifier for authenticator device as returned by enroll * @param code Code Generated by an authenticator device */ private async _challengeAndVerify( params: MFAChallengeAndVerifyParams ): Promise<AuthMFAVerifyResponse> { const { data: challengeData, error: challengeError } = await this._challenge({ factorId: params.factorId, }) if (challengeError) { return { data: null, error: challengeError } } return await this._verify({ factorId: params.factorId, challengeId: challengeData.id, code: params.code, }) } /** * Displays all devices for a given user */ private async _listFactors(): Promise<AuthMFAListFactorsResponse> { const { data: { user }, error: userError, } = await this.getUser() if (userError) { return { data: null, error: userError } } const factors = user?.factors || [] const totp = factors.filter( (factor) => factor.factor_type === 'totp' && factor.status === 'verified' ) return { data: { all: factors, totp, }, error: null, } } /** * Gets the current and next authenticator assurance level (AAL) * and the current authentication methods for the session (AMR) */ private async _getAuthenticatorAssuranceLevel(): Promise<AuthMFAGetAuthenticatorAssuranceLevelResponse> { const { data: { session }, error: sessionError, } = await this.getSession() if (sessionError) { return { data: null, error: sessionError } } if (!session) { return { data: { currentLevel: null, nextLevel: null, currentAuthenticationMethods: [] }, error: null, } } const payload = this._decodeJWT(session.access_token) let currentLevel: AuthenticatorAssuranceLevels | null = null if (payload.aal) { currentLevel = payload.aal } let nextLevel: AuthenticatorAssuranceLevels | null = currentLevel const verifiedFactors = session.user.factors?.filter((factor: Factor) => factor.status === 'verified') ?? [] if (verifiedFactors.length > 0) { nextLevel = 'aal2' } const currentAuthenticationMethods = payload.amr || [] return { data: { currentLevel, nextLevel, currentAuthenticationMethods }, error: null } } }