From bc092fe5c64dfe6b93066c3673c10efb9cf00420 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 20 Dec 2022 18:49:38 +0100 Subject: [PATCH 1/3] feat: add new auto refresh token algorithm --- src/GoTrueClient.ts | 184 ++++++++++++++++++++++++-------------- src/lib/helpers.ts | 44 ++++++++- src/lib/types.ts | 4 +- test/GoTrueClient.test.ts | 7 +- 4 files changed, 166 insertions(+), 73 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index cbe99a9ac..a61ebd85d 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -27,6 +27,8 @@ import { resolveFetch, setItemAsync, uuid, + retryable, + sleep, } from './lib/helpers' import localStorageAdapter from './lib/local-storage' import { polyfillGlobalThis } from './lib/polyfills' @@ -79,6 +81,13 @@ const DEFAULT_OPTIONS: Omit, 'fetch' | 'storage'> headers: DEFAULT_HEADERS, } +/** Current session will be checked for refresh will be checked at this interval. */ +const AUTO_REFRESH_TICK_DURATION = 10 * 1000 + +/** + * A token refresh will be attempted this many ticks before the current session expires. */ +const AUTO_REFRESH_TICK_THRESHOLD = 3 + export default class GoTrueClient { /** * Namespace for the GoTrue admin methods. @@ -104,8 +113,7 @@ export default class GoTrueClient { protected persistSession: boolean protected storage: SupportedStorage protected stateChangeEmitters: Map = new Map() - protected refreshTokenTimer?: ReturnType - protected networkRetries = 0 + protected autoRefreshTicker: ReturnType | null = null protected refreshingDeferred: Deferred | null = null /** * Keeps track of the async client initialization. @@ -142,7 +150,6 @@ export default class GoTrueClient { this.fetch = resolveFetch(settings.fetch) this.detectSessionInUrl = settings.detectSessionInUrl - this.initialize() this.mfa = { verify: this._verify.bind(this), enroll: this._enroll.bind(this), @@ -152,6 +159,8 @@ export default class GoTrueClient { challengeAndVerify: this._challengeAndVerify.bind(this), getAuthenticatorAssuranceLevel: this._getAuthenticatorAssuranceLevel.bind(this), } + + this.initialize() } /** @@ -902,11 +911,26 @@ export default class GoTrueClient { */ private async _refreshAccessToken(refreshToken: string): Promise { try { - return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, { - body: { refresh_token: refreshToken }, - headers: this.headers, - xform: _sessionResponse, - }) + const startedAt = Date.now() + + // will attempt to refresh the token with exponential backoff + return await retryable( + async (attempt) => { + await sleep(attempt * 200) // 0, 200, 400, 800, ... + + return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, { + body: { refresh_token: refreshToken }, + headers: this.headers, + xform: _sessionResponse, + }) + }, + (attempt, _, result) => + result && + result.error && + result.error instanceof AuthRetryableFetchError && + // retryable only if the request can be sent before the backoff overflows the tick duration + Date.now() + (attempt + 1) * 200 - startedAt < AUTO_REFRESH_TICK_DURATION + ) } catch (error) { if (isAuthError(error)) { return { data: { session: null, user: null }, error } @@ -965,24 +989,12 @@ export default class GoTrueClient { 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() } @@ -1051,14 +1063,6 @@ export default class GoTrueClient { 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) } @@ -1074,42 +1078,91 @@ export default class GoTrueClient { } else { this.inMemorySession = null } + } - if (this.refreshTokenTimer) { - clearTimeout(this.refreshTokenTimer) + /** + * Starts an auto-refresh process in the background. The session is checked + * every few seconds. Close to the time of expiration a process is started to + * refresh the session. If refreshing fails it will be retried for as long as + * necessary. + * + * If you set the {@link GoTrueClientOptions#autoRefreshToken} you don't need + * to call this function, it will be called for you. + * + * On browsers the refresh process works only when the tab/window is in the + * foreground to conserve resources as well as prevent race conditions and + * flooding auth with requests. + * + * On non-browser platforms the refresh process works *continuously* in the + * background, which may not be desireable. You should hook into your + * platform's foreground indication mechanism and call these methods + * appropriately to conserve resources. + * + * {@see #stopAutoRefresh} + */ + async startAutoRefresh() { + await this.stopAutoRefresh() + this.autoRefreshTicker = setInterval( + () => this._autoRefreshTokenTick(), + AUTO_REFRESH_TICK_DURATION + ) + + // run the tick immediately + await this._autoRefreshTokenTick() + } + + /** + * Stops an active auto refresh process running in the background (if any). + * See {@link #startAutoRefresh} for more details. + */ + async stopAutoRefresh() { + const ticker = this.autoRefreshTicker + this.autoRefreshTicker = null + + if (ticker) { + clearInterval(ticker) } } /** - * Clear and re-create refresh token timer - * @param value time intervals in milliseconds. - * @param session The current session. + * Runs the auto refresh token tick. */ - private _startAutoRefreshToken(value: number) { - if (this.refreshTokenTimer) clearTimeout(this.refreshTokenTimer) - if (value <= 0 || !this.autoRefreshToken) return + private async _autoRefreshTokenTick() { + const now = Date.now() - this.refreshTokenTimer = setTimeout(async () => { - this.networkRetries++ + try { const { data: { session }, - error: sessionError, + error, } = 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 + + if (!session || !session.refresh_token || !session.expires_at) { + return } - }, value) - if (typeof this.refreshTokenTimer.unref === 'function') this.refreshTokenTimer.unref() + + // session will expire in this many ticks (or has already expired if <= 0) + const expiresInTicks = Math.floor((now - session.expires_at) / AUTO_REFRESH_TICK_DURATION) + + if (expiresInTicks < AUTO_REFRESH_TICK_THRESHOLD) { + await this._callRefreshToken(session.refresh_token) + } + } catch (e: any) { + console.error('Auto refresh tick failed with error. This is likely a transient error.', e) + } } + /** + * Registers callbacks on the browser / platform, which in-turn run + * algorithms when the browser window/tab are in foreground. On non-browser + * platforms it assumes always foreground. + */ private _handleVisibilityChange() { if (!isBrowser() || !window?.addEventListener) { + if (this.autoRefreshToken) { + // in non-browser environments the refresh token ticker runs always + this.startAutoRefresh() + } + return false } @@ -1118,6 +1171,16 @@ export default class GoTrueClient { if (document.visibilityState === 'visible') { await this.initializePromise await this._recoverAndRefresh() + + if (this.autoRefreshToken) { + // in browser environments the refresh token ticker runs only on focused tabs + // which prevents race conditions + this.startAutoRefresh() + } + } else if (document.visibilityState === 'hidden') { + if (this.autoRefreshToken) { + this.stopAutoRefresh() + } } }) } catch (error) { @@ -1173,10 +1236,7 @@ export default class GoTrueClient { } /** - * 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 + * {@see GoTrueMFAApi#enroll} */ private async _enroll(params: MFAEnrollParams): Promise { try { @@ -1213,9 +1273,7 @@ export default class GoTrueClient { } /** - * 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 + * {@see GoTrueMFAApi#verify} */ private async _verify(params: MFAVerifyParams): Promise { try { @@ -1254,8 +1312,7 @@ export default class GoTrueClient { } /** - * Creates a challenge which a user can verify against - * @param factorId System assigned identifier for authenticator device as returned by enroll + * {@see GoTrueMFAApi#challenge} */ private async _challenge(params: MFAChallengeParams): Promise { try { @@ -1282,9 +1339,7 @@ export default class GoTrueClient { } /** - * 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 + * {@see GoTrueMFAApi#challengeAndVerify} */ private async _challengeAndVerify( params: MFAChallengeAndVerifyParams @@ -1303,7 +1358,7 @@ export default class GoTrueClient { } /** - * Displays all devices for a given user + * {@see GoTrueMFAApi#listFactors} */ private async _listFactors(): Promise { const { @@ -1329,8 +1384,7 @@ export default class GoTrueClient { } /** - * Gets the current and next authenticator assurance level (AAL) - * and the current authentication methods for the session (AMR) + * {@see GoTrueMFAApi#getAuthenticatorAssuranceLevel} */ private async _getAuthenticatorAssuranceLevel(): Promise { const { diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 943369c65..9d31e7761 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -131,7 +131,7 @@ export class Deferred { export function decodeJWTPayload(token: string) { // Regex checks for base64url format const base64UrlRegex = /^([a-z0-9_-]{4})*($|[a-z0-9_-]{3}=?$|[a-z0-9_-]{2}(==)?$)$/i - + const parts = token.split('.') if (parts.length !== 3) { @@ -145,3 +145,45 @@ export function decodeJWTPayload(token: string) { const base64Url = parts[1] return JSON.parse(decodeBase64URL(base64Url)) } + +/** + * Creates a promise that resolves to null after some time. + */ +export function sleep(time: number): Promise { + return new Promise((accept) => { + setTimeout(() => accept(null), time) + }) +} + +/** + * Converts the provided async function into a retryable function. Each result + * or thrown error is sent to the isRetryable function which should return true + * if the function should run again. + */ +export function retryable( + fn: (attempt: number) => Promise, + isRetryable: (attempt: number, error: any | null, result?: T) => boolean +): Promise { + const promise = new Promise((accept, reject) => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(async () => { + for (let attempt = 0; attempt < Infinity; attempt++) { + try { + const result = await fn(attempt) + + if (!isRetryable(attempt, null, result)) { + accept(result) + return + } + } catch (e: any) { + if (!isRetryable(attempt, e)) { + reject(e) + return + } + } + } + })() + }) + + return promise +} diff --git a/src/lib/types.ts b/src/lib/types.ts index df5e4de46..bd358cf84 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -912,8 +912,8 @@ export type CallRefreshTokenResult = export type Pagination = { [key: string]: any - nextPage: number | null, - lastPage: number, + nextPage: number | null + lastPage: number total: number } diff --git a/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index 38e5cfaf0..c81b2d574 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -98,7 +98,7 @@ describe('GoTrueClient', () => { expect(error).toBeNull() expect(data.session).not.toBeNull() - /** + /** * Sign out the user to verify setSession, getSession and updateUser * are truly working; because the signUp method will already save the session. * And that session will be available to getSession and updateUser, @@ -129,10 +129,7 @@ describe('GoTrueClient', () => { * getSession has been added to verify setSession is also saving * the session, not just returning it. */ - const { - data: getSessionData, - error: getSessionError - } = await authWithSession.getSession() + const { data: getSessionData, error: getSessionError } = await authWithSession.getSession() expect(getSessionError).toBeNull() expect(getSessionData).not.toBeNull() From 62df4713e9ecbbc0685cb47414a80cc83ab728a8 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 5 Jan 2023 19:30:21 +0100 Subject: [PATCH 2/3] fix: fixes to the auto refresh alg --- src/GoTrueClient.ts | 55 +++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index a61ebd85d..8beb65ccc 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -222,7 +222,7 @@ export default class GoTrueClient { error: new AuthUnknownError('Unexpected error during initialization', error), } } finally { - this._handleVisibilityChange() + await this._handleVisibilityChange() } } @@ -1141,7 +1141,9 @@ export default class GoTrueClient { } // session will expire in this many ticks (or has already expired if <= 0) - const expiresInTicks = Math.floor((now - session.expires_at) / AUTO_REFRESH_TICK_DURATION) + const expiresInTicks = Math.floor( + (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION + ) if (expiresInTicks < AUTO_REFRESH_TICK_THRESHOLD) { await this._callRefreshToken(session.refresh_token) @@ -1156,7 +1158,7 @@ export default class GoTrueClient { * algorithms when the browser window/tab are in foreground. On non-browser * platforms it assumes always foreground. */ - private _handleVisibilityChange() { + private async _handleVisibilityChange() { if (!isBrowser() || !window?.addEventListener) { if (this.autoRefreshToken) { // in non-browser environments the refresh token ticker runs always @@ -1167,27 +1169,42 @@ export default class GoTrueClient { } try { - window?.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible') { - await this.initializePromise - await this._recoverAndRefresh() - - if (this.autoRefreshToken) { - // in browser environments the refresh token ticker runs only on focused tabs - // which prevents race conditions - this.startAutoRefresh() - } - } else if (document.visibilityState === 'hidden') { - if (this.autoRefreshToken) { - this.stopAutoRefresh() - } - } - }) + window?.addEventListener( + 'visibilitychange', + async () => await this._onVisibilityChanged(false) + ) + + // now immediately call the visbility changed callback to setup with the + // current visbility state + await this._onVisibilityChanged(true) // initial call } catch (error) { console.error('_handleVisibilityChange', error) } } + /** + * Callback registered with `window.addEventListener('visibilitychange')`. + */ + private async _onVisibilityChanged(isInitial: boolean) { + if (document.visibilityState === 'visible') { + if (!isInitial) { + // initial visibility change setup is handled in another flow under #initialize() + await this.initializePromise + await this._recoverAndRefresh() + } + + if (this.autoRefreshToken) { + // in browser environments the refresh token ticker runs only on focused tabs + // which prevents race conditions + this.startAutoRefresh() + } + } else if (document.visibilityState === 'hidden') { + if (this.autoRefreshToken) { + this.stopAutoRefresh() + } + } + } + /** * 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. From 36b88723ced3420ff5706c571d21282d8a063c52 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 5 Jan 2023 19:32:21 +0100 Subject: [PATCH 3/3] apply suggestion from @J0 Co-authored-by: Joel Lee --- src/GoTrueClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 8beb65ccc..cb9a3fd66 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -81,7 +81,7 @@ const DEFAULT_OPTIONS: Omit, 'fetch' | 'storage'> headers: DEFAULT_HEADERS, } -/** Current session will be checked for refresh will be checked at this interval. */ +/** Current session will be checked for refresh at this interval. */ const AUTO_REFRESH_TICK_DURATION = 10 * 1000 /**