diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index f5ef15884e..e9950cf944 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -207,6 +207,7 @@ const DEFAULT_OPTIONS: Omit< throwOnError: false, lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove. skipAutoInitialize: false, + maxAutoRefreshFailures: 0, experimental: {}, } @@ -371,6 +372,16 @@ export default class GoTrueClient { * Only consulted when a custom `lock` is supplied. TODO(v3): remove. */ protected lockAcquireTimeout: number + /** + * Maximum consecutive auto-refresh tick failures before the client stops + * retrying and emits `TOKEN_REFRESH_FAILED`. 0 means no limit (default). + */ + protected maxAutoRefreshFailures: number + /** + * Tracks consecutive auto-refresh tick failures. Reset to 0 on any + * successful token refresh. + */ + protected autoRefreshFailureCount: number = 0 /** * Opt-in flags for experimental features. Defaults to an empty object. * See `GoTrueClientOptions.experimental`. @@ -449,6 +460,7 @@ export default class GoTrueClient { // (including supabase-js tests) read it off the client to verify option // flow-through. this.lockAcquireTimeout = settings.lockAcquireTimeout + this.maxAutoRefreshFailures = settings.maxAutoRefreshFailures // TODO(v3): remove. Legacy opt-in path preserved for backwards // compatibility with callers passing a custom `lock` (typically React @@ -5188,6 +5200,16 @@ export default class GoTrueClient { // _saveSession is always called whenever a new session has been acquired // so we can safely suppress the warning returned by future getSession calls this.suppressGetSessionWarning = true + + // A new session means auth is working — reset the failure counter. + this.autoRefreshFailureCount = 0 + + // In non-browser environments (React Native, Node) nothing restarts the + // ticker after the limit was hit. Re-enable it here so that a successful + // sign-in or setSession recovers auto-refresh. + if (this.autoRefreshToken && this.autoRefreshTicker === null && !isBrowser()) { + void this._startAutoRefresh() + } // Create a shallow copy to work with, to avoid mutating the original session object if it's used elsewhere const sessionToProcess = { ...session } @@ -5233,6 +5255,7 @@ export default class GoTrueClient { // The session is gone — no point holding on to a cached refresh failure // for a token that no longer exists. Synchronous, before any `await`. this.lastRefreshFailure = null + this.autoRefreshFailureCount = 0 this.suppressGetSessionWarning = false @@ -5477,10 +5500,19 @@ export default class GoTrueClient { return await this._useSession(async (result) => { const { data: { session }, + error, } = result if (!session || !session.refresh_token || !session.expires_at) { this._debug('#_autoRefreshTokenTick()', 'no session') + if (error && isAuthRetryableFetchError(error)) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `session load failed, consecutive failures: ${this.autoRefreshFailureCount}` + ) + await this._handleAutoRefreshFailure(session ?? null) + } return } @@ -5494,14 +5526,30 @@ export default class GoTrueClient { ) if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) { - await this._callRefreshToken(session.refresh_token) + const refreshResult = await this._callRefreshToken(session.refresh_token) + if (refreshResult.error) { + if (isAuthRetryableFetchError(refreshResult.error)) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `refresh failed, consecutive failures: ${this.autoRefreshFailureCount}` + ) + await this._handleAutoRefreshFailure(session ?? null) + } + } } }) } catch (e) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `tick threw, consecutive failures: ${this.autoRefreshFailureCount}` + ) console.error( 'Auto refresh tick failed with error. This is likely a transient error.', e ) + await this._handleAutoRefreshFailure() } } finally { this._debug('#_autoRefreshTokenTick()', 'end') @@ -5532,10 +5580,22 @@ export default class GoTrueClient { await this._useSession(async (result) => { const { data: { session }, + error, } = result if (!session || !session.refresh_token || !session.expires_at) { this._debug('#_autoRefreshTokenTick()', 'no session') + // If __loadSession returned an error, it means an internal refresh + // was attempted and failed (e.g. expired session). Only count + // retryable errors (network/5xx) toward the limit. + if (error && isAuthRetryableFetchError(error)) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `session load failed, consecutive failures: ${this.autoRefreshFailureCount}` + ) + await this._handleAutoRefreshFailure(session ?? null) + } return } @@ -5550,17 +5610,70 @@ export default class GoTrueClient { ) if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) { - await this._callRefreshToken(session.refresh_token) + const refreshResult = await this._callRefreshToken(session.refresh_token) + if (refreshResult.error) { + // Only count retryable errors (network/5xx) toward the limit. + // Non-retryable errors (revoked token, rate limit) have their + // own handling in _callRefreshToken and should not stop auto-refresh. + if (isAuthRetryableFetchError(refreshResult.error)) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `refresh failed, consecutive failures: ${this.autoRefreshFailureCount}` + ) + await this._handleAutoRefreshFailure(session ?? null) + } + } } }) } catch (e) { + this.autoRefreshFailureCount++ + this._debug( + '#_autoRefreshTokenTick()', + `tick threw, consecutive failures: ${this.autoRefreshFailureCount}` + ) console.error('Auto refresh tick failed with error. This is likely a transient error.', e) + await this._handleAutoRefreshFailure() } } finally { this._debug('#_autoRefreshTokenTick()', 'end') } } + /** + * Checks whether the consecutive auto-refresh failure count has reached + * `maxAutoRefreshFailures`. If so, stops the auto-refresh ticker and + * emits a `TOKEN_REFRESH_FAILED` event so the application can react + * (e.g. redirect to login, show a banner). + * + * This is a no-op when `maxAutoRefreshFailures` is 0 (the default), + * preserving the existing "retry forever" behavior. + * + * Note: in browsers, visibility changes restart the auto-refresh ticker + * (which resets the counter), so the stop is not permanent — the next tab + * focus will resume refresh attempts with a fresh budget. + */ + private async _handleAutoRefreshFailure(session: Session | null = null): Promise { + if ( + this.maxAutoRefreshFailures > 0 && + this.autoRefreshFailureCount >= this.maxAutoRefreshFailures && + this.autoRefreshTicker !== null + ) { + this._debug( + '#_handleAutoRefreshFailure()', + `max failures reached (${this.maxAutoRefreshFailures}), stopping auto-refresh` + ) + await this._stopAutoRefresh() + try { + await this._notifyAllSubscribers('TOKEN_REFRESH_FAILED', session, false) + } catch (e) { + // Swallow subscriber errors so the failure handler cannot re-enter + // itself via the tick's catch block. + console.error('Error in TOKEN_REFRESH_FAILED subscriber:', e) + } + } + } + /** * Registers callbacks on the browser / platform, which in-turn run * algorithms when the browser window/tab are in foreground. On non-browser diff --git a/packages/core/auth-js/src/lib/types.ts b/packages/core/auth-js/src/lib/types.ts index 0172348a0a..9a779a0c3c 100644 --- a/packages/core/auth-js/src/lib/types.ts +++ b/packages/core/auth-js/src/lib/types.ts @@ -57,6 +57,7 @@ export type AuthChangeEvent = | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED' + | 'TOKEN_REFRESH_FAILED' | 'USER_UPDATED' | AuthChangeEventMFA @@ -161,6 +162,28 @@ export type GoTrueClientOptions = { */ lockAcquireTimeout?: number + /** + * Maximum number of consecutive auto-refresh tick failures before the + * client stops attempting to refresh the token and emits a + * `TOKEN_REFRESH_FAILED` event via `onAuthStateChange`. + * + * Only retryable errors (network failures, 5xx) count toward the limit. + * Non-retryable errors (revoked token, rate limits) do not. Each tick is + * ~30 seconds, so `maxAutoRefreshFailures: 5` means roughly 2.5 minutes + * of consecutive failures before the event fires. + * + * In browsers, tab refocus restarts the ticker and resets the counter + * (via `_onVisibilityChanged`), so the stop is not permanent. In + * non-browser environments (React Native, Node), a successful + * `setSession` or sign-in restarts auto-refresh automatically. + * + * Set to `0` to disable the limit (retry forever, which is the default + * behavior). + * + * @default 0 (no limit — retry indefinitely) + */ + maxAutoRefreshFailures?: number + /** * If true, skips automatic initialization in constructor. Useful for SSR * contexts where initialization timing must be controlled to prevent race diff --git a/packages/core/auth-js/test/GoTrueClient.autoRefreshFailures.test.ts b/packages/core/auth-js/test/GoTrueClient.autoRefreshFailures.test.ts new file mode 100644 index 0000000000..f3b7b3d205 --- /dev/null +++ b/packages/core/auth-js/test/GoTrueClient.autoRefreshFailures.test.ts @@ -0,0 +1,347 @@ +import GoTrueClient from '../src/GoTrueClient' +import { memoryLocalStorageAdapter } from '../src/lib/local-storage' +import { setItemAsync } from '../src/lib/helpers' +import { AuthRetryableFetchError, AuthApiError } from '../src/lib/errors' +import type { AuthChangeEvent, LockFunc, Session } from '../src/lib/types' + +const GOTRUE_URL = 'http://localhost:9999' + +/** + * Creates a session that the tick considers "needs refresh" (within threshold). + */ +function sessionNeedingRefresh(): Session { + const expiresAt = Math.floor(Date.now() / 1000) + 60 + + return { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 60, + expires_at: expiresAt, + token_type: 'bearer', + user: { + id: 'test-user-id', + aud: 'authenticated', + role: 'authenticated', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + } +} + +/** + * Creates a GoTrueClient with _useSession mocked to always return a session + * that is about to expire. + */ +function createTestClient(options: { maxAutoRefreshFailures?: number; useLock?: boolean } = {}) { + const storage = memoryLocalStorageAdapter() + + const lockMock: LockFunc | undefined = options.useLock + ? (jest.fn(async (_name: string, _timeout: number, fn: () => Promise) => await fn()) as unknown as LockFunc) + : undefined + + const client = new GoTrueClient({ + url: GOTRUE_URL, + autoRefreshToken: false, + persistSession: true, + storage, + fetch: jest.fn().mockResolvedValue(new Response('{}', { status: 200 })), + maxAutoRefreshFailures: options.maxAutoRefreshFailures ?? 0, + skipAutoInitialize: true, + ...(lockMock ? { lock: lockMock } : {}), + }) + + // Mock _useSession to bypass storage/initialization and directly provide a session + const session = sessionNeedingRefresh() + jest.spyOn(client as any, '_useSession').mockImplementation(async (fn: any) => { + return await fn({ data: { session }, error: null }) + }) + + return { client, storage, lockMock } +} + +function retryableError() { + return new AuthRetryableFetchError('Network error', 0) +} + +function nonRetryableError() { + return new AuthApiError('Token revoked', 401, 'refresh_token_not_found') +} + +describe('GoTrueClient maxAutoRefreshFailures', () => { + it('retries indefinitely when maxAutoRefreshFailures is 0 (default)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 0 }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + for (let i = 0; i < 10; i++) { + await (client as any)._autoRefreshTokenTick() + } + + // Counter increments but auto-refresh is NOT stopped (no limit) + expect((client as any).autoRefreshFailureCount).toBe(10) + }) + + it('stops auto-refresh and emits TOKEN_REFRESH_FAILED after reaching max failures', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 3 }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + const events: AuthChangeEvent[] = [] + client.onAuthStateChange((event) => { + events.push(event) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + events.length = 0 + + await client.startAutoRefresh() + expect((client as any).autoRefreshTicker).not.toBeNull() + + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + expect(events).toContain('TOKEN_REFRESH_FAILED') + expect((client as any).autoRefreshTicker).toBeNull() + }) + + it('resets the failure count on successful refresh (via _saveSession)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + const refreshSpy = jest.spyOn(client as any, '_callRefreshToken') + refreshSpy.mockResolvedValueOnce({ data: null, error: retryableError() }) + refreshSpy.mockResolvedValueOnce({ data: null, error: retryableError() }) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(1) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(2) + + // Simulate what happens when a refresh succeeds: _saveSession is called + await (client as any)._saveSession(sessionNeedingRefresh()) + expect((client as any).autoRefreshFailureCount).toBe(0) + }) + + it('does not emit TOKEN_REFRESH_FAILED when failures stay below the limit', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + const refreshSpy = jest.spyOn(client as any, '_callRefreshToken') + refreshSpy.mockResolvedValue({ data: null, error: retryableError() }) + + const events: AuthChangeEvent[] = [] + client.onAuthStateChange((event) => { + events.push(event) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + events.length = 0 + + // Only 2 failures, limit is 5 + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + expect(events).not.toContain('TOKEN_REFRESH_FAILED') + expect((client as any).autoRefreshFailureCount).toBe(2) + }) + + + it('does not count non-retryable errors toward the limit', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 3 }) + + // Non-retryable error (e.g. token revoked, 401) + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: nonRetryableError(), + }) + + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + // Should NOT have counted these toward the limit + expect((client as any).autoRefreshFailureCount).toBe(0) + }) + + it('increments failure count when _autoRefreshTokenTick throws', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + jest.spyOn(client as any, '_callRefreshToken').mockRejectedValue(new Error('Unexpected crash')) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(1) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(2) + }) + + it('counts failures when _useSession returns a retryable error (expired session)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + jest.spyOn(client as any, '_useSession').mockImplementation(async (fn: any) => { + return await fn({ + data: { session: null }, + error: retryableError(), + }) + }) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(1) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(2) + }) + + it('does not count when session is null without an error (user not logged in)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + jest.spyOn(client as any, '_useSession').mockImplementation(async (fn: any) => { + return await fn({ data: { session: null }, error: null }) + }) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(0) + }) + + it('resets the counter when _saveSession is called (new session acquired)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(2) + + // Simulate a new session being saved (e.g. sign-in, setSession) + await (client as any)._saveSession(sessionNeedingRefresh()) + expect((client as any).autoRefreshFailureCount).toBe(0) + }) + + it('resets the counter when _removeSession is called (sign-out)', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 5 }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + await (client as any)._autoRefreshTokenTick() + expect((client as any).autoRefreshFailureCount).toBe(1) + + await (client as any)._removeSession() + expect((client as any).autoRefreshFailureCount).toBe(0) + }) + + it('works with the legacy lock path', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 3, useLock: true }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + const events: AuthChangeEvent[] = [] + client.onAuthStateChange((event) => { + events.push(event) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + events.length = 0 + + await client.startAutoRefresh() + + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + expect(events).toContain('TOKEN_REFRESH_FAILED') + expect((client as any).autoRefreshTicker).toBeNull() + }) + + it('does not fire TOKEN_REFRESH_FAILED twice if subscriber throws', async () => { + const { client } = createTestClient({ maxAutoRefreshFailures: 2 }) + + jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + let eventCount = 0 + client.onAuthStateChange((event) => { + if (event === 'TOKEN_REFRESH_FAILED') { + eventCount++ + throw new Error('Subscriber error') + } + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + await client.startAutoRefresh() + + // 2 failures should trigger exactly one TOKEN_REFRESH_FAILED + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + expect(eventCount).toBe(1) + }) + + it('counts failures through the real session load path', async () => { + const storage = memoryLocalStorageAdapter() + + // Seed a session that's about to expire (within tick threshold) + // 100s from now: NOT expired by __loadSession (100000 > 90000) + // but tick triggers: Math.floor(100000/30000) = 3 <= 3 + const session = sessionNeedingRefresh() + session.expires_at = Math.floor(Date.now() / 1000) + 100 + await setItemAsync(storage, 'supabase.auth.token', session) + + const client = new GoTrueClient({ + url: GOTRUE_URL, + autoRefreshToken: false, + persistSession: true, + storage, + fetch: jest.fn().mockResolvedValue( + new Response('{}', { status: 200 }) + ), + maxAutoRefreshFailures: 3, + skipAutoInitialize: true, + }) + + // Mock _callRefreshToken but NOT _useSession — real __loadSession runs + const refreshSpy = jest.spyOn(client as any, '_callRefreshToken').mockResolvedValue({ + data: null, + error: retryableError(), + }) + + // Don't call initialize() — avoid any side effects on the seeded session. + // The tick only needs storage to have a valid session. + + const events: AuthChangeEvent[] = [] + client.onAuthStateChange((event) => { + events.push(event) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + events.length = 0 + + await client.startAutoRefresh() + + // Run ticks — each goes through real __loadSession, then hits mocked refresh + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + await (client as any)._autoRefreshTokenTick() + + // Verify _callRefreshToken was actually called via real __loadSession path + expect(refreshSpy).toHaveBeenCalled() + expect((client as any).autoRefreshFailureCount).toBeGreaterThanOrEqual(3) + expect(events).toContain('TOKEN_REFRESH_FAILED') + expect((client as any).autoRefreshTicker).toBeNull() + }) +}) diff --git a/packages/core/supabase-js/src/SupabaseClient.ts b/packages/core/supabase-js/src/SupabaseClient.ts index 992470da00..96d9064cd6 100644 --- a/packages/core/supabase-js/src/SupabaseClient.ts +++ b/packages/core/supabase-js/src/SupabaseClient.ts @@ -619,6 +619,7 @@ export default class SupabaseClient< experimental, lockAcquireTimeout, skipAutoInitialize, + maxAutoRefreshFailures, }: SupabaseAuthClientOptions, headers?: Record, fetch?: Fetch @@ -644,6 +645,7 @@ export default class SupabaseClient< fetch, lockAcquireTimeout, skipAutoInitialize, + maxAutoRefreshFailures, // auth checks if there is a custom authorizaiton header using this flag // so it knows whether to return an error when getUser is called with no session hasCustomAuthorizationHeader: Object.keys(this.headers).some( diff --git a/packages/core/supabase-js/src/lib/types.ts b/packages/core/supabase-js/src/lib/types.ts index 40671d7b53..b8795cf3a8 100644 --- a/packages/core/supabase-js/src/lib/types.ts +++ b/packages/core/supabase-js/src/lib/types.ts @@ -236,6 +236,25 @@ export type SupabaseClientOptions = { * @default false */ skipAutoInitialize?: SupabaseAuthClientOptions['skipAutoInitialize'] + /** + * Maximum number of consecutive auto-refresh tick failures before the + * client stops attempting to refresh the token and emits a + * `TOKEN_REFRESH_FAILED` event via `onAuthStateChange`. + * + * Only retryable errors (network failures, 5xx) count toward the limit. + * Non-retryable errors (revoked token, rate limits) do not. Each tick is + * ~30 seconds, so `maxAutoRefreshFailures: 5` means roughly 2.5 minutes + * of consecutive failures before the event fires. + * + * In browsers, tab refocus restarts the ticker and resets the counter. + * In non-browser environments (React Native, Node), a successful + * `setSession` or sign-in restarts auto-refresh automatically. + * + * Set to `0` to disable the limit (retry forever, which is the default). + * + * @default 0 + */ + maxAutoRefreshFailures?: SupabaseAuthClientOptions['maxAutoRefreshFailures'] } /** * Options passed to the realtime-js instance diff --git a/packages/core/supabase-js/test/unit/SupabaseAuthClient.test.ts b/packages/core/supabase-js/test/unit/SupabaseAuthClient.test.ts index cb8110e3a3..3202ad8ddb 100644 --- a/packages/core/supabase-js/test/unit/SupabaseAuthClient.test.ts +++ b/packages/core/supabase-js/test/unit/SupabaseAuthClient.test.ts @@ -155,3 +155,25 @@ test('createClient should accept auth.skipAutoInitialize and wire it to auth cli initializeSpy.mockRestore() } }) + +test('_initSupabaseAuthClient should pass through maxAutoRefreshFailures option', () => { + const client = new SupabaseClient('https://example.supabase.com', 'supabaseKey') + const authClient = client['_initSupabaseAuthClient']( + { ...authSettings, maxAutoRefreshFailures: 5 }, + undefined, + undefined + ) + + expect((authClient as unknown as { maxAutoRefreshFailures: number }).maxAutoRefreshFailures).toBe( + 5 + ) +}) + +test('createClient should accept auth.maxAutoRefreshFailures and wire it to auth client', () => { + const supa = new SupabaseClient('https://example.supabase.com', 'supabaseKey', { + auth: { maxAutoRefreshFailures: 5 }, + }) + expect((supa.auth as unknown as { maxAutoRefreshFailures: number }).maxAutoRefreshFailures).toBe( + 5 + ) +})