@supabase/auth-js 2.107.0-canary.0 → 2.107.0-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/main/GoTrueClient.d.ts +68 -14
  2. package/dist/main/GoTrueClient.d.ts.map +1 -1
  3. package/dist/main/GoTrueClient.js +331 -107
  4. package/dist/main/GoTrueClient.js.map +1 -1
  5. package/dist/main/lib/errors.d.ts +24 -0
  6. package/dist/main/lib/errors.d.ts.map +1 -1
  7. package/dist/main/lib/errors.js +31 -1
  8. package/dist/main/lib/errors.js.map +1 -1
  9. package/dist/main/lib/locks.d.ts +28 -34
  10. package/dist/main/lib/locks.d.ts.map +1 -1
  11. package/dist/main/lib/locks.js +28 -34
  12. package/dist/main/lib/locks.js.map +1 -1
  13. package/dist/main/lib/types.d.ts +16 -27
  14. package/dist/main/lib/types.d.ts.map +1 -1
  15. package/dist/main/lib/types.js.map +1 -1
  16. package/dist/main/lib/version.d.ts +1 -1
  17. package/dist/main/lib/version.js +1 -1
  18. package/dist/module/GoTrueClient.d.ts +68 -14
  19. package/dist/module/GoTrueClient.d.ts.map +1 -1
  20. package/dist/module/GoTrueClient.js +333 -109
  21. package/dist/module/GoTrueClient.js.map +1 -1
  22. package/dist/module/lib/errors.d.ts +24 -0
  23. package/dist/module/lib/errors.d.ts.map +1 -1
  24. package/dist/module/lib/errors.js +28 -0
  25. package/dist/module/lib/errors.js.map +1 -1
  26. package/dist/module/lib/locks.d.ts +28 -34
  27. package/dist/module/lib/locks.d.ts.map +1 -1
  28. package/dist/module/lib/locks.js +28 -34
  29. package/dist/module/lib/locks.js.map +1 -1
  30. package/dist/module/lib/types.d.ts +16 -27
  31. package/dist/module/lib/types.d.ts.map +1 -1
  32. package/dist/module/lib/types.js.map +1 -1
  33. package/dist/module/lib/version.d.ts +1 -1
  34. package/dist/module/lib/version.js +1 -1
  35. package/dist/tsconfig.module.tsbuildinfo +1 -1
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/migrations/lockless-coordination.md +89 -0
  38. package/package.json +1 -1
  39. package/src/GoTrueClient.ts +397 -137
  40. package/src/lib/errors.ts +32 -0
  41. package/src/lib/locks.ts +29 -34
  42. package/src/lib/types.ts +16 -27
  43. package/src/lib/version.ts +1 -1
@@ -16,11 +16,13 @@ import {
16
16
  AuthInvalidTokenResponseError,
17
17
  AuthPKCECodeVerifierMissingError,
18
18
  AuthPKCEGrantCodeExchangeError,
19
+ AuthRefreshDiscardedError,
19
20
  AuthSessionMissingError,
20
21
  AuthUnknownError,
21
22
  isAuthApiError,
22
23
  isAuthError,
23
24
  isAuthImplicitGrantRedirectError,
25
+ isAuthRefreshDiscardedError,
24
26
  isAuthRetryableFetchError,
25
27
  isAuthSessionMissingError,
26
28
  } from './lib/errors'
@@ -195,11 +197,17 @@ const DEFAULT_OPTIONS: Omit<
195
197
  debug: false,
196
198
  hasCustomAuthorizationHeader: false,
197
199
  throwOnError: false,
198
- lockAcquireTimeout: 5000, // 5 seconds
200
+ lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
199
201
  skipAutoInitialize: false,
200
202
  experimental: {},
201
203
  }
202
204
 
205
+ /**
206
+ * No-op lock used internally as a placeholder. Kept so older test setups that
207
+ * inject this exact reference do not break; new code never sees it because
208
+ * `this.lock` stays `null` when no custom lock is supplied (lockless path).
209
+ * TODO(v3): remove with the legacy lock path.
210
+ */
203
211
  async function lockNoOp<R>(name: string, acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
204
212
  return await fn()
205
213
  }
@@ -280,6 +288,14 @@ export default class GoTrueClient {
280
288
  protected autoRefreshTickTimeout: ReturnType<typeof setTimeout> | null = null
281
289
  protected visibilityChangedCallback: (() => Promise<any>) | null = null
282
290
  protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null
291
+ /**
292
+ * Monotonic counter incremented at the top of `_removeSession`, before any
293
+ * `await`. The commit guard inside `_callRefreshToken` captures this value
294
+ * before `_saveSession` and re-checks it after, so a `signOut` that
295
+ * interleaves inside `_saveSession`'s storage-write awaits is still caught
296
+ * (the post-fetch storage snapshot alone misses that window).
297
+ */
298
+ protected _sessionRemovalEpoch = 0
283
299
  /**
284
300
  * Keeps track of the async client initialization.
285
301
  * When null or not yet resolved the auth state is `unknown`
@@ -297,10 +313,19 @@ export default class GoTrueClient {
297
313
  protected hasCustomAuthorizationHeader = false
298
314
  protected suppressGetSessionWarning = false
299
315
  protected fetch: Fetch
300
- protected lock: LockFunc
316
+ /**
317
+ * Custom lock function passed via `settings.lock`. When non-null, every auth
318
+ * operation runs inside `_acquireLock`. When null (the default), the client
319
+ * uses its lockless coordination (refresh single-flight + commit guard).
320
+ * TODO(v3): remove along with the legacy lock path.
321
+ */
322
+ protected lock: LockFunc | null = null
301
323
  protected lockAcquired = false
302
324
  protected pendingInLock: Promise<any>[] = []
303
325
  protected throwOnError: boolean
326
+ /**
327
+ * Only consulted when a custom `lock` is supplied. TODO(v3): remove.
328
+ */
304
329
  protected lockAcquireTimeout: number
305
330
  /**
306
331
  * Opt-in flags for experimental features. Defaults to an empty object.
@@ -371,19 +396,23 @@ export default class GoTrueClient {
371
396
  this.url = settings.url
372
397
  this.headers = settings.headers
373
398
  this.fetch = resolveFetch(settings.fetch)
374
- this.lock = settings.lock || lockNoOp
375
399
  this.detectSessionInUrl = settings.detectSessionInUrl
376
400
  this.flowType = settings.flowType
377
401
  this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader
378
402
  this.throwOnError = settings.throwOnError
403
+
404
+ // Always wire `lockAcquireTimeout` even on the lockless path: consumers
405
+ // (including supabase-js tests) read it off the client to verify option
406
+ // flow-through.
379
407
  this.lockAcquireTimeout = settings.lockAcquireTimeout
380
408
 
381
- if (settings.lock) {
409
+ // TODO(v3): remove. Legacy opt-in path preserved for backwards
410
+ // compatibility with callers passing a custom `lock` (typically React
411
+ // Native `processLock` or Node multi-process setups). When `settings.lock`
412
+ // is null the client uses its lockless coordination — no `navigator.locks`
413
+ // by default, no implicit `processLock`.
414
+ if (settings.lock != null) {
382
415
  this.lock = settings.lock
383
- } else if (this.persistSession && isBrowser() && globalThis?.navigator?.locks) {
384
- this.lock = navigatorLock
385
- } else {
386
- this.lock = lockNoOp
387
416
  }
388
417
 
389
418
  if (!this.jwks) {
@@ -518,9 +547,13 @@ export default class GoTrueClient {
518
547
  }
519
548
 
520
549
  this.initializePromise = (async () => {
521
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
522
- return await this._initialize()
523
- })
550
+ if (this.lock != null) {
551
+ // TODO(v3): remove legacy lock path
552
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
553
+ return await this._initialize()
554
+ })
555
+ }
556
+ return await this._initialize()
524
557
  })()
525
558
 
526
559
  return await this.initializePromise
@@ -1405,9 +1438,14 @@ export default class GoTrueClient {
1405
1438
  async exchangeCodeForSession(authCode: string): Promise<AuthTokenResponse> {
1406
1439
  await this.initializePromise
1407
1440
 
1408
- return this._acquireLock(this.lockAcquireTimeout, async () => {
1409
- return this._exchangeCodeForSession(authCode)
1410
- })
1441
+ if (this.lock != null) {
1442
+ // TODO(v3): remove legacy lock path
1443
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1444
+ return this._exchangeCodeForSession(authCode)
1445
+ })
1446
+ }
1447
+
1448
+ return this._exchangeCodeForSession(authCode)
1411
1449
  }
1412
1450
 
1413
1451
  /**
@@ -2444,9 +2482,14 @@ export default class GoTrueClient {
2444
2482
  async reauthenticate(): Promise<AuthResponse> {
2445
2483
  await this.initializePromise
2446
2484
 
2447
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2448
- return await this._reauthenticate()
2449
- })
2485
+ if (this.lock != null) {
2486
+ // TODO(v3): remove legacy lock path
2487
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2488
+ return await this._reauthenticate()
2489
+ })
2490
+ }
2491
+
2492
+ return await this._reauthenticate()
2450
2493
  }
2451
2494
 
2452
2495
  private async _reauthenticate(): Promise<AuthResponse> {
@@ -2593,7 +2636,7 @@ export default class GoTrueClient {
2593
2636
  * - If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
2594
2637
  * - When using in a browser, or you've called `startAutoRefresh()` in your environment (React Native, etc.) this function always returns a valid access token without refreshing the session itself, as this is done in the background. This function returns very fast.
2595
2638
  * - **IMPORTANT SECURITY NOTICE:** If using an insecure storage medium, such as cookies or request headers, the user object returned by this function **must not be trusted**. Always verify the JWT using `getClaims()` or your own JWT verification library to securely establish the user's identity and access. You can also use `getUser()` to fetch the user object directly from the Auth server for this purpose.
2596
- * - When using in a browser, this function is synchronized across all tabs using the [LockManager](https://developer.mozilla.org/en-US/docs/Web/API/LockManager) API. In other environments make sure you've defined a proper `lock` property, if necessary, to make sure there are no race conditions while the session is being refreshed.
2639
+ * - Cross-tab refresh races are handled by the GoTrue server (the rotated token from the first tab is returned to subsequent tabs via the parent-of-active mechanism), so no client-side serialization is needed.
2597
2640
  *
2598
2641
  * @example Get the session data
2599
2642
  * ```js
@@ -2661,17 +2704,26 @@ export default class GoTrueClient {
2661
2704
  async getSession() {
2662
2705
  await this.initializePromise
2663
2706
 
2664
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2665
- return this._useSession(async (result) => {
2666
- return result
2707
+ if (this.lock != null) {
2708
+ // TODO(v3): remove legacy lock path
2709
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2710
+ return this._useSession(async (result) => {
2711
+ return result
2712
+ })
2667
2713
  })
2668
- })
2714
+ }
2669
2715
 
2670
- return result
2716
+ return await this._useSession(async (result) => {
2717
+ return result
2718
+ })
2671
2719
  }
2672
2720
 
2673
2721
  /**
2674
2722
  * Acquires a global lock based on the storage key.
2723
+ *
2724
+ * TODO(v3): remove along with the legacy lock path. Only called when
2725
+ * `this.lock` is non-null (custom lock supplied via constructor). The
2726
+ * default lockless path bypasses this entirely.
2675
2727
  */
2676
2728
  private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
2677
2729
  this._debug('#_acquireLock', 'begin', acquireTimeout)
@@ -2700,7 +2752,7 @@ export default class GoTrueClient {
2700
2752
  return result
2701
2753
  }
2702
2754
 
2703
- return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
2755
+ return await this.lock!(`lock:${this.storageKey}`, acquireTimeout, async () => {
2704
2756
  this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
2705
2757
 
2706
2758
  try {
@@ -2742,10 +2794,9 @@ export default class GoTrueClient {
2742
2794
  }
2743
2795
 
2744
2796
  /**
2745
- * Use instead of {@link #getSession} inside the library. It is
2746
- * semantically usually what you want, as getting a session involves some
2747
- * processing afterwards that requires only one client operating on the
2748
- * session at once across multiple tabs or processes.
2797
+ * Use instead of {@link #getSession} inside the library. Loads the session
2798
+ * via `__loadSession` (which may trigger a refresh if the access token is
2799
+ * within the expiry margin) and runs `fn` with the result.
2749
2800
  */
2750
2801
  private async _useSession<R>(
2751
2802
  fn: (
@@ -2773,7 +2824,10 @@ export default class GoTrueClient {
2773
2824
  this._debug('#_useSession', 'begin')
2774
2825
 
2775
2826
  try {
2776
- // the use of __loadSession here is the only correct use of the function!
2827
+ // Concurrent callers may both reach __loadSession; storage reads are
2828
+ // idempotent, and the only write path inside it (refresh) is
2829
+ // single-flighted downstream by `refreshingDeferred` in
2830
+ // `_callRefreshToken`. No serialization is needed at this layer.
2777
2831
  const result = await this.__loadSession()
2778
2832
 
2779
2833
  return await fn(result)
@@ -2809,7 +2863,8 @@ export default class GoTrueClient {
2809
2863
  > {
2810
2864
  this._debug('#__loadSession()', 'begin')
2811
2865
 
2812
- if (!this.lockAcquired) {
2866
+ if (this.lock != null && !this.lockAcquired) {
2867
+ // TODO(v3): remove. Only meaningful on the legacy lock path.
2813
2868
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack)
2814
2869
  }
2815
2870
 
@@ -2976,9 +3031,15 @@ export default class GoTrueClient {
2976
3031
 
2977
3032
  await this.initializePromise
2978
3033
 
2979
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2980
- return await this._getUser()
2981
- })
3034
+ let result: UserResponse
3035
+ if (this.lock != null) {
3036
+ // TODO(v3): remove legacy lock path
3037
+ result = await this._acquireLock(this.lockAcquireTimeout, async () => {
3038
+ return await this._getUser()
3039
+ })
3040
+ } else {
3041
+ result = await this._getUser()
3042
+ }
2982
3043
 
2983
3044
  if (result.data.user) {
2984
3045
  this.suppressGetSessionWarning = true
@@ -3153,9 +3214,14 @@ export default class GoTrueClient {
3153
3214
  ): Promise<UserResponse> {
3154
3215
  await this.initializePromise
3155
3216
 
3156
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3157
- return await this._updateUser(attributes, options)
3158
- })
3217
+ if (this.lock != null) {
3218
+ // TODO(v3): remove legacy lock path
3219
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3220
+ return await this._updateUser(attributes, options)
3221
+ })
3222
+ }
3223
+
3224
+ return await this._updateUser(attributes, options)
3159
3225
  }
3160
3226
 
3161
3227
  protected async _updateUser(
@@ -3342,9 +3408,14 @@ export default class GoTrueClient {
3342
3408
  }): Promise<AuthResponse> {
3343
3409
  await this.initializePromise
3344
3410
 
3345
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3346
- return await this._setSession(currentSession)
3347
- })
3411
+ if (this.lock != null) {
3412
+ // TODO(v3): remove legacy lock path
3413
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3414
+ return await this._setSession(currentSession)
3415
+ })
3416
+ }
3417
+
3418
+ return await this._setSession(currentSession)
3348
3419
  }
3349
3420
 
3350
3421
  protected async _setSession(currentSession: {
@@ -3533,9 +3604,14 @@ export default class GoTrueClient {
3533
3604
  async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> {
3534
3605
  await this.initializePromise
3535
3606
 
3536
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3537
- return await this._refreshSession(currentSession)
3538
- })
3607
+ if (this.lock != null) {
3608
+ // TODO(v3): remove legacy lock path
3609
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3610
+ return await this._refreshSession(currentSession)
3611
+ })
3612
+ }
3613
+
3614
+ return await this._refreshSession(currentSession)
3539
3615
  }
3540
3616
 
3541
3617
  protected async _refreshSession(currentSession?: {
@@ -3785,9 +3861,14 @@ export default class GoTrueClient {
3785
3861
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3786
3862
  await this.initializePromise
3787
3863
 
3788
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3789
- return await this._signOut(options)
3790
- })
3864
+ if (this.lock != null) {
3865
+ // TODO(v3): remove legacy lock path
3866
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3867
+ return await this._signOut(options)
3868
+ })
3869
+ }
3870
+
3871
+ return await this._signOut(options)
3791
3872
  }
3792
3873
 
3793
3874
  protected async _signOut(
@@ -3834,16 +3915,19 @@ export default class GoTrueClient {
3834
3915
  }
3835
3916
 
3836
3917
  /**
3837
- * Avoid using an async function inside `onAuthStateChange` as you might end
3838
- * up with a deadlock. The callback function runs inside an exclusive lock,
3839
- * so calling other Supabase Client APIs that also try to acquire the
3840
- * exclusive lock, might cause a deadlock. This behavior is observable across
3841
- * tabs. In the next major library version, this behavior will not be supported.
3842
- *
3843
- * Receive a notification every time an auth event happens.
3918
+ * Receive a notification every time an auth event happens. Common reentry
3919
+ * patterns (`getUser`, `setSession`, reading the session from inside a
3920
+ * handler) complete normally. One hazard remains: calling `refreshSession`
3921
+ * (or anything that routes through `_callRefreshToken`) from inside a
3922
+ * `TOKEN_REFRESHED` handler. `refreshingDeferred` resolves only after
3923
+ * `_notifyAllSubscribers` returns, so the inner refresh dedupes onto the
3924
+ * outer's unresolved promise and the two wait on each other.
3844
3925
  *
3845
3926
  * @param callback A callback function to be invoked when an auth event happens.
3846
- * @deprecated Due to the possibility of deadlocks with async functions as callbacks, use the version without an async function.
3927
+ *
3928
+ * @deprecated Async callbacks can deadlock when they trigger a nested
3929
+ * refresh from a `TOKEN_REFRESHED` event. Prefer the sync overload, or move
3930
+ * refresh-triggering work outside the callback.
3847
3931
  */
3848
3932
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3849
3933
  data: { subscription: Subscription }
@@ -3856,18 +3940,8 @@ export default class GoTrueClient {
3856
3940
  * - Subscribes to important events occurring on the user's session.
3857
3941
  * - Use on the frontend/client. It is less useful on the server.
3858
3942
  * - Events are emitted across tabs to keep your application's UI up-to-date. Some events can fire very frequently, based on the number of tabs open. Use a quick and efficient callback function, and defer or debounce as many operations as you can to be performed outside of the callback.
3859
- * - **Important:** A callback can be an `async` function and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by using `await` on a call to another method of the Supabase library.
3860
- * - Avoid using `async` functions as callbacks.
3861
- * - Limit the number of `await` calls in `async` callbacks.
3862
- * - Do not use other Supabase functions in the callback function. If you must, dispatch the functions once the callback has finished executing. Use this as a quick way to achieve this:
3863
- * ```js
3864
- * supabase.auth.onAuthStateChange((event, session) => {
3865
- * setTimeout(async () => {
3866
- * // await on other Supabase function here
3867
- * // this runs right after the callback has finished
3868
- * }, 0)
3869
- * })
3870
- * ```
3943
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
3944
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3871
3945
  * - Emitted events:
3872
3946
  * - `INITIAL_SESSION`
3873
3947
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -4058,9 +4132,14 @@ export default class GoTrueClient {
4058
4132
  ;(async () => {
4059
4133
  await this.initializePromise
4060
4134
 
4061
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4062
- this._emitInitialSession(id)
4063
- })
4135
+ if (this.lock != null) {
4136
+ // TODO(v3): remove legacy lock path
4137
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4138
+ this._emitInitialSession(id)
4139
+ })
4140
+ } else {
4141
+ await this._emitInitialSession(id)
4142
+ }
4064
4143
  })()
4065
4144
 
4066
4145
  return { data: { subscription } }
@@ -4455,7 +4534,10 @@ export default class GoTrueClient {
4455
4534
  * @param refreshToken A valid refresh token that was returned on login.
4456
4535
  */
4457
4536
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
4458
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
4537
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4538
+ // fragment of the token in the debug tag, even when `debug: true` is
4539
+ // enabled (logs may be forwarded to third-party services).
4540
+ const debugName = `#_refreshAccessToken()`
4459
4541
  this._debug(debugName, 'begin')
4460
4542
 
4461
4543
  try {
@@ -4608,15 +4690,22 @@ export default class GoTrueClient {
4608
4690
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4609
4691
 
4610
4692
  if (error) {
4611
- console.error(error)
4612
-
4613
- if (!isAuthRetryableFetchError(error)) {
4614
- this._debug(
4615
- debugName,
4616
- 'refresh failed with a non-retryable error, removing the session',
4617
- error
4618
- )
4619
- await this._removeSession()
4693
+ // AuthRefreshDiscardedError means a concurrent signOut already
4694
+ // cleared storage and fired SIGNED_OUT. Don't run _removeSession
4695
+ // again here, or we'll emit a duplicate SIGNED_OUT.
4696
+ if (isAuthRefreshDiscardedError(error)) {
4697
+ this._debug(debugName, 'refresh discarded by commit guard', error)
4698
+ } else {
4699
+ console.error(error)
4700
+
4701
+ if (!isAuthRetryableFetchError(error)) {
4702
+ this._debug(
4703
+ debugName,
4704
+ 'refresh failed with a non-retryable error, removing the session',
4705
+ error
4706
+ )
4707
+ await this._removeSession()
4708
+ }
4620
4709
  }
4621
4710
  }
4622
4711
  }
@@ -4669,18 +4758,85 @@ export default class GoTrueClient {
4669
4758
  return this.refreshingDeferred.promise
4670
4759
  }
4671
4760
 
4672
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
4761
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4762
+ // fragment of the token in the debug tag, even when `debug: true` is
4763
+ // enabled (logs may be forwarded to third-party services).
4764
+ const debugName = `#_callRefreshToken()`
4673
4765
 
4674
4766
  this._debug(debugName, 'begin')
4675
4767
 
4676
4768
  try {
4677
4769
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4678
4770
 
4771
+ // Snapshot storage before the fetch. The commit guard discards the
4772
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
4773
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
4774
+ // another tab's refresh rewrote the slot. Callers passing
4775
+ // externally-sourced tokens (SSR cookie handoff, multi-account
4776
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
4777
+ // start from a null snapshot OR from a non-null snapshot whose
4778
+ // refresh_token differs from the one they're hydrating; in both
4779
+ // cases the guard fires only when storage was *modified between
4780
+ // snapshots*, not when the input token disagrees with what's stored.
4781
+ const storedAtStart = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4782
+
4679
4783
  const { data, error } = await this._refreshAccessToken(refreshToken)
4680
4784
  if (error) throw error
4681
4785
  if (!data.session) throw new AuthSessionMissingError()
4682
4786
 
4787
+ const storedAfter = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4788
+ const storageChangedUnderUs =
4789
+ storedAtStart !== null &&
4790
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token)
4791
+
4792
+ if (storageChangedUnderUs) {
4793
+ this._debug(
4794
+ debugName,
4795
+ 'commit guard: storage changed since refresh started, discarding rotated tokens',
4796
+ {
4797
+ // Presence indicators only — never log refresh token fragments,
4798
+ // even partial. Logs may be forwarded to third-party services.
4799
+ startedWith: 'present',
4800
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4801
+ }
4802
+ )
4803
+ const discarded: CallRefreshTokenResult = {
4804
+ data: null,
4805
+ error: new AuthRefreshDiscardedError(),
4806
+ }
4807
+ this.refreshingDeferred.resolve(discarded)
4808
+ return discarded
4809
+ }
4810
+
4811
+ // Second leg of the commit guard: close the TOCTOU window between the
4812
+ // synchronous `storageChangedUnderUs` check and the actual storage
4813
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4814
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4815
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4816
+ // the save and re-check after; if it advanced, undo the write directly
4817
+ // (do NOT call `_removeSession` — that would emit a duplicate
4818
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4819
+ const epochBeforeSave = this._sessionRemovalEpoch
4820
+
4683
4821
  await this._saveSession(data.session)
4822
+
4823
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4824
+ this._debug(
4825
+ debugName,
4826
+ 'commit guard (post-save): _removeSession ran during _saveSession, undoing write'
4827
+ )
4828
+ await removeItemAsync(this.storage, this.storageKey)
4829
+ if (this.userStorage) {
4830
+ await removeItemAsync(this.userStorage, this.storageKey + '-user')
4831
+ }
4832
+ const discarded: CallRefreshTokenResult = {
4833
+ data: null,
4834
+ error: new AuthRefreshDiscardedError(),
4835
+ }
4836
+ this.refreshingDeferred.resolve(discarded)
4837
+ return discarded
4838
+ }
4839
+
4684
4840
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4685
4841
 
4686
4842
  const result = { data: data.session, error: null }
@@ -4792,6 +4948,11 @@ export default class GoTrueClient {
4792
4948
  }
4793
4949
 
4794
4950
  private async _removeSession() {
4951
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
4952
+ // post-save check sees the increment whenever this method has started —
4953
+ // even if it hasn't finished. Pairs with the epoch check in
4954
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
4955
+ this._sessionRemovalEpoch += 1
4795
4956
  this._debug('#_removeSession()')
4796
4957
 
4797
4958
  this.suppressGetSessionWarning = false
@@ -4980,58 +5141,144 @@ export default class GoTrueClient {
4980
5141
  await this._stopAutoRefresh()
4981
5142
  }
4982
5143
 
5144
+ /**
5145
+ * Tears down the client's background work: stops the auto-refresh interval,
5146
+ * removes the `visibilitychange` listener, closes the cross-tab
5147
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
5148
+ *
5149
+ * Call this from cleanup hooks when the client is being replaced before
5150
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
5151
+ * cases. Any in-flight `fetch` calls continue to completion and may still
5152
+ * write to storage; dispose doesn't abort them or erase storage.
5153
+ *
5154
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
5155
+ * disposed instance can still persist a rotated session to storage after
5156
+ * `dispose()` returns. A subsequent `createClient` against the same
5157
+ * `storageKey` will pick up that session on its next read. If you need
5158
+ * strict isolation between client lifecycles, await any pending auth
5159
+ * operation before calling `dispose()` (or change the `storageKey` for
5160
+ * the replacement client).
5161
+ *
5162
+ * Safe to call repeatedly.
5163
+ *
5164
+ * @category Auth
5165
+ *
5166
+ * @example Cleanup on React unmount
5167
+ * ```ts
5168
+ * useEffect(() => {
5169
+ * const client = createClient(...)
5170
+ * return () => { client.auth.dispose() }
5171
+ * }, [])
5172
+ * ```
5173
+ */
5174
+ async dispose(): Promise<void> {
5175
+ this._removeVisibilityChangedCallback()
5176
+ await this._stopAutoRefresh()
5177
+ this.broadcastChannel?.close()
5178
+ this.broadcastChannel = null
5179
+ this.stateChangeEmitters.clear()
5180
+ }
5181
+
4983
5182
  /**
4984
5183
  * Runs the auto refresh token tick.
4985
5184
  */
4986
5185
  private async _autoRefreshTokenTick() {
4987
5186
  this._debug('#_autoRefreshTokenTick()', 'begin')
4988
5187
 
4989
- try {
4990
- await this._acquireLock(0, async () => {
4991
- try {
4992
- const now = Date.now()
4993
-
5188
+ if (this.lock != null) {
5189
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
5190
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
5191
+ // that's the fail-fast skip path that lets the tick bail out instead
5192
+ // of queuing behind a long-running operation.
5193
+ try {
5194
+ await this._acquireLock(0, async () => {
4994
5195
  try {
4995
- return await this._useSession(async (result) => {
4996
- const {
4997
- data: { session },
4998
- } = result
4999
-
5000
- if (!session || !session.refresh_token || !session.expires_at) {
5001
- this._debug('#_autoRefreshTokenTick()', 'no session')
5002
- return
5003
- }
5196
+ const now = Date.now()
5197
+ try {
5198
+ return await this._useSession(async (result) => {
5199
+ const {
5200
+ data: { session },
5201
+ } = result
5202
+
5203
+ if (!session || !session.refresh_token || !session.expires_at) {
5204
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5205
+ return
5206
+ }
5004
5207
 
5005
- // session will expire in this many ticks (or has already expired if <= 0)
5006
- const expiresInTicks = Math.floor(
5007
- (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5008
- )
5208
+ const expiresInTicks = Math.floor(
5209
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5210
+ )
5009
5211
 
5010
- this._debug(
5011
- '#_autoRefreshTokenTick()',
5012
- `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5013
- )
5212
+ this._debug(
5213
+ '#_autoRefreshTokenTick()',
5214
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5215
+ )
5014
5216
 
5015
- if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5016
- await this._callRefreshToken(session.refresh_token)
5017
- }
5018
- })
5019
- } catch (e) {
5020
- console.error(
5021
- 'Auto refresh tick failed with error. This is likely a transient error.',
5022
- e
5023
- )
5217
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5218
+ await this._callRefreshToken(session.refresh_token)
5219
+ }
5220
+ })
5221
+ } catch (e) {
5222
+ console.error(
5223
+ 'Auto refresh tick failed with error. This is likely a transient error.',
5224
+ e
5225
+ )
5226
+ }
5227
+ } finally {
5228
+ this._debug('#_autoRefreshTokenTick()', 'end')
5024
5229
  }
5025
- } finally {
5026
- this._debug('#_autoRefreshTokenTick()', 'end')
5230
+ })
5231
+ } catch (e) {
5232
+ if (e instanceof LockAcquireTimeoutError) {
5233
+ this._debug('auto refresh token tick lock not available')
5234
+ } else {
5235
+ throw e
5027
5236
  }
5028
- })
5029
- } catch (e) {
5030
- if (e instanceof LockAcquireTimeoutError) {
5031
- this._debug('auto refresh token tick lock not available')
5032
- } else {
5033
- throw e
5034
5237
  }
5238
+ return
5239
+ }
5240
+
5241
+ // Lockless default: skip if a refresh is already in flight.
5242
+ // `_callRefreshToken` also dedupes via the same field; this is just a
5243
+ // fast-path skip to avoid an unnecessary storage read.
5244
+ if (this.refreshingDeferred !== null) {
5245
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping')
5246
+ return
5247
+ }
5248
+
5249
+ try {
5250
+ const now = Date.now()
5251
+
5252
+ try {
5253
+ await this._useSession(async (result) => {
5254
+ const {
5255
+ data: { session },
5256
+ } = result
5257
+
5258
+ if (!session || !session.refresh_token || !session.expires_at) {
5259
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5260
+ return
5261
+ }
5262
+
5263
+ // session will expire in this many ticks (or has already expired if <= 0)
5264
+ const expiresInTicks = Math.floor(
5265
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5266
+ )
5267
+
5268
+ this._debug(
5269
+ '#_autoRefreshTokenTick()',
5270
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5271
+ )
5272
+
5273
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5274
+ await this._callRefreshToken(session.refresh_token)
5275
+ }
5276
+ })
5277
+ } catch (e) {
5278
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
5279
+ }
5280
+ } finally {
5281
+ this._debug('#_autoRefreshTokenTick()', 'end')
5035
5282
  }
5036
5283
  }
5037
5284
 
@@ -5088,24 +5335,29 @@ export default class GoTrueClient {
5088
5335
  if (!calledFromInitialize) {
5089
5336
  // called when the visibility has changed, i.e. the browser
5090
5337
  // transitioned from hidden -> visible so we need to see if the session
5091
- // should be recovered immediately... but to do that we need to acquire
5092
- // the lock first asynchronously
5338
+ // should be recovered
5093
5339
  await this.initializePromise
5094
5340
 
5095
- await this._acquireLock(this.lockAcquireTimeout, async () => {
5341
+ if (this.lock != null) {
5342
+ // TODO(v3): remove legacy lock path
5343
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
5344
+ if (document.visibilityState !== 'visible') {
5345
+ this._debug(
5346
+ methodName,
5347
+ 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5348
+ )
5349
+ return
5350
+ }
5351
+ await this._recoverAndRefresh()
5352
+ })
5353
+ } else {
5096
5354
  if (document.visibilityState !== 'visible') {
5097
- this._debug(
5098
- methodName,
5099
- 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5100
- )
5101
-
5102
- // visibility has changed while waiting for the lock, abort
5355
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
5103
5356
  return
5104
5357
  }
5105
-
5106
5358
  // recover the session
5107
5359
  await this._recoverAndRefresh()
5108
- })
5360
+ }
5109
5361
  }
5110
5362
  } else if (document.visibilityState === 'hidden') {
5111
5363
  if (this.autoRefreshToken) {
@@ -5237,7 +5489,7 @@ export default class GoTrueClient {
5237
5489
  params: MFAVerifyWebauthnParams<T>
5238
5490
  ): Promise<AuthMFAVerifyResponse>
5239
5491
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5240
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5492
+ const run = async (): Promise<AuthMFAVerifyResponse> => {
5241
5493
  try {
5242
5494
  return await this._useSession(async (result) => {
5243
5495
  const { data: sessionData, error: sessionError } = result
@@ -5308,7 +5560,13 @@ export default class GoTrueClient {
5308
5560
  }
5309
5561
  throw error
5310
5562
  }
5311
- })
5563
+ }
5564
+
5565
+ if (this.lock != null) {
5566
+ // TODO(v3): remove legacy lock path
5567
+ return this._acquireLock(this.lockAcquireTimeout, run)
5568
+ }
5569
+ return run()
5312
5570
  }
5313
5571
 
5314
5572
  /**
@@ -5324,7 +5582,7 @@ export default class GoTrueClient {
5324
5582
  params: MFAChallengeWebauthnParams
5325
5583
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5326
5584
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5327
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5585
+ const run = async (): Promise<AuthMFAChallengeResponse> => {
5328
5586
  try {
5329
5587
  return await this._useSession(async (result) => {
5330
5588
  const { data: sessionData, error: sessionError } = result
@@ -5397,7 +5655,13 @@ export default class GoTrueClient {
5397
5655
  }
5398
5656
  throw error
5399
5657
  }
5400
- })
5658
+ }
5659
+
5660
+ if (this.lock != null) {
5661
+ // TODO(v3): remove legacy lock path
5662
+ return this._acquireLock(this.lockAcquireTimeout, run)
5663
+ }
5664
+ return run()
5401
5665
  }
5402
5666
 
5403
5667
  /**
@@ -5406,9 +5670,6 @@ export default class GoTrueClient {
5406
5670
  private async _challengeAndVerify(
5407
5671
  params: MFAChallengeAndVerifyParams
5408
5672
  ): Promise<AuthMFAVerifyResponse> {
5409
- // both _challenge and _verify independently acquire the lock, so no need
5410
- // to acquire it here
5411
-
5412
5673
  const { data: challengeData, error: challengeError } = await this._challenge({
5413
5674
  factorId: params.factorId,
5414
5675
  })
@@ -5427,7 +5688,6 @@ export default class GoTrueClient {
5427
5688
  * {@see GoTrueMFAApi#listFactors}
5428
5689
  */
5429
5690
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5430
- // use #getUser instead of #_getUser as the former acquires a lock
5431
5691
  const {
5432
5692
  data: { user },
5433
5693
  error: userError,