@supabase/auth-js 2.106.2 → 2.107.0-beta.0

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 (49) hide show
  1. package/AGENTS.md +11 -0
  2. package/dist/main/GoTrueClient.d.ts +68 -14
  3. package/dist/main/GoTrueClient.d.ts.map +1 -1
  4. package/dist/main/GoTrueClient.js +331 -107
  5. package/dist/main/GoTrueClient.js.map +1 -1
  6. package/dist/main/lib/errors.d.ts +24 -0
  7. package/dist/main/lib/errors.d.ts.map +1 -1
  8. package/dist/main/lib/errors.js +31 -1
  9. package/dist/main/lib/errors.js.map +1 -1
  10. package/dist/main/lib/locks.d.ts +28 -34
  11. package/dist/main/lib/locks.d.ts.map +1 -1
  12. package/dist/main/lib/locks.js +28 -34
  13. package/dist/main/lib/locks.js.map +1 -1
  14. package/dist/main/lib/types.d.ts +16 -27
  15. package/dist/main/lib/types.d.ts.map +1 -1
  16. package/dist/main/lib/types.js.map +1 -1
  17. package/dist/main/lib/version.d.ts +1 -1
  18. package/dist/main/lib/version.d.ts.map +1 -1
  19. package/dist/main/lib/version.js +1 -1
  20. package/dist/main/lib/version.js.map +1 -1
  21. package/dist/module/GoTrueClient.d.ts +68 -14
  22. package/dist/module/GoTrueClient.d.ts.map +1 -1
  23. package/dist/module/GoTrueClient.js +333 -109
  24. package/dist/module/GoTrueClient.js.map +1 -1
  25. package/dist/module/lib/errors.d.ts +24 -0
  26. package/dist/module/lib/errors.d.ts.map +1 -1
  27. package/dist/module/lib/errors.js +28 -0
  28. package/dist/module/lib/errors.js.map +1 -1
  29. package/dist/module/lib/locks.d.ts +28 -34
  30. package/dist/module/lib/locks.d.ts.map +1 -1
  31. package/dist/module/lib/locks.js +28 -34
  32. package/dist/module/lib/locks.js.map +1 -1
  33. package/dist/module/lib/types.d.ts +16 -27
  34. package/dist/module/lib/types.d.ts.map +1 -1
  35. package/dist/module/lib/types.js.map +1 -1
  36. package/dist/module/lib/version.d.ts +1 -1
  37. package/dist/module/lib/version.d.ts.map +1 -1
  38. package/dist/module/lib/version.js +1 -1
  39. package/dist/module/lib/version.js.map +1 -1
  40. package/dist/tsconfig.module.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/migrations/README.md +25 -0
  43. package/migrations/lockless-coordination.md +89 -0
  44. package/package.json +4 -2
  45. package/src/GoTrueClient.ts +397 -137
  46. package/src/lib/errors.ts +32 -0
  47. package/src/lib/locks.ts +29 -34
  48. package/src/lib/types.ts +16 -27
  49. 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?: {
@@ -3783,9 +3859,14 @@ export default class GoTrueClient {
3783
3859
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3784
3860
  await this.initializePromise
3785
3861
 
3786
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3787
- return await this._signOut(options)
3788
- })
3862
+ if (this.lock != null) {
3863
+ // TODO(v3): remove legacy lock path
3864
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3865
+ return await this._signOut(options)
3866
+ })
3867
+ }
3868
+
3869
+ return await this._signOut(options)
3789
3870
  }
3790
3871
 
3791
3872
  protected async _signOut(
@@ -3832,16 +3913,19 @@ export default class GoTrueClient {
3832
3913
  }
3833
3914
 
3834
3915
  /**
3835
- * Avoid using an async function inside `onAuthStateChange` as you might end
3836
- * up with a deadlock. The callback function runs inside an exclusive lock,
3837
- * so calling other Supabase Client APIs that also try to acquire the
3838
- * exclusive lock, might cause a deadlock. This behavior is observable across
3839
- * tabs. In the next major library version, this behavior will not be supported.
3840
- *
3841
- * Receive a notification every time an auth event happens.
3916
+ * Receive a notification every time an auth event happens. Common reentry
3917
+ * patterns (`getUser`, `setSession`, reading the session from inside a
3918
+ * handler) complete normally. One hazard remains: calling `refreshSession`
3919
+ * (or anything that routes through `_callRefreshToken`) from inside a
3920
+ * `TOKEN_REFRESHED` handler. `refreshingDeferred` resolves only after
3921
+ * `_notifyAllSubscribers` returns, so the inner refresh dedupes onto the
3922
+ * outer's unresolved promise and the two wait on each other.
3842
3923
  *
3843
3924
  * @param callback A callback function to be invoked when an auth event happens.
3844
- * @deprecated Due to the possibility of deadlocks with async functions as callbacks, use the version without an async function.
3925
+ *
3926
+ * @deprecated Async callbacks can deadlock when they trigger a nested
3927
+ * refresh from a `TOKEN_REFRESHED` event. Prefer the sync overload, or move
3928
+ * refresh-triggering work outside the callback.
3845
3929
  */
3846
3930
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3847
3931
  data: { subscription: Subscription }
@@ -3854,18 +3938,8 @@ export default class GoTrueClient {
3854
3938
  * - Subscribes to important events occurring on the user's session.
3855
3939
  * - Use on the frontend/client. It is less useful on the server.
3856
3940
  * - 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.
3857
- * - **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.
3858
- * - Avoid using `async` functions as callbacks.
3859
- * - Limit the number of `await` calls in `async` callbacks.
3860
- * - 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:
3861
- * ```js
3862
- * supabase.auth.onAuthStateChange((event, session) => {
3863
- * setTimeout(async () => {
3864
- * // await on other Supabase function here
3865
- * // this runs right after the callback has finished
3866
- * }, 0)
3867
- * })
3868
- * ```
3941
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
3942
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3869
3943
  * - Emitted events:
3870
3944
  * - `INITIAL_SESSION`
3871
3945
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -4056,9 +4130,14 @@ export default class GoTrueClient {
4056
4130
  ;(async () => {
4057
4131
  await this.initializePromise
4058
4132
 
4059
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4060
- this._emitInitialSession(id)
4061
- })
4133
+ if (this.lock != null) {
4134
+ // TODO(v3): remove legacy lock path
4135
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4136
+ this._emitInitialSession(id)
4137
+ })
4138
+ } else {
4139
+ await this._emitInitialSession(id)
4140
+ }
4062
4141
  })()
4063
4142
 
4064
4143
  return { data: { subscription } }
@@ -4453,7 +4532,10 @@ export default class GoTrueClient {
4453
4532
  * @param refreshToken A valid refresh token that was returned on login.
4454
4533
  */
4455
4534
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
4456
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
4535
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4536
+ // fragment of the token in the debug tag, even when `debug: true` is
4537
+ // enabled (logs may be forwarded to third-party services).
4538
+ const debugName = `#_refreshAccessToken()`
4457
4539
  this._debug(debugName, 'begin')
4458
4540
 
4459
4541
  try {
@@ -4606,15 +4688,22 @@ export default class GoTrueClient {
4606
4688
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4607
4689
 
4608
4690
  if (error) {
4609
- console.error(error)
4610
-
4611
- if (!isAuthRetryableFetchError(error)) {
4612
- this._debug(
4613
- debugName,
4614
- 'refresh failed with a non-retryable error, removing the session',
4615
- error
4616
- )
4617
- await this._removeSession()
4691
+ // AuthRefreshDiscardedError means a concurrent signOut already
4692
+ // cleared storage and fired SIGNED_OUT. Don't run _removeSession
4693
+ // again here, or we'll emit a duplicate SIGNED_OUT.
4694
+ if (isAuthRefreshDiscardedError(error)) {
4695
+ this._debug(debugName, 'refresh discarded by commit guard', error)
4696
+ } else {
4697
+ console.error(error)
4698
+
4699
+ if (!isAuthRetryableFetchError(error)) {
4700
+ this._debug(
4701
+ debugName,
4702
+ 'refresh failed with a non-retryable error, removing the session',
4703
+ error
4704
+ )
4705
+ await this._removeSession()
4706
+ }
4618
4707
  }
4619
4708
  }
4620
4709
  }
@@ -4667,18 +4756,85 @@ export default class GoTrueClient {
4667
4756
  return this.refreshingDeferred.promise
4668
4757
  }
4669
4758
 
4670
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
4759
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4760
+ // fragment of the token in the debug tag, even when `debug: true` is
4761
+ // enabled (logs may be forwarded to third-party services).
4762
+ const debugName = `#_callRefreshToken()`
4671
4763
 
4672
4764
  this._debug(debugName, 'begin')
4673
4765
 
4674
4766
  try {
4675
4767
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4676
4768
 
4769
+ // Snapshot storage before the fetch. The commit guard discards the
4770
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
4771
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
4772
+ // another tab's refresh rewrote the slot. Callers passing
4773
+ // externally-sourced tokens (SSR cookie handoff, multi-account
4774
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
4775
+ // start from a null snapshot OR from a non-null snapshot whose
4776
+ // refresh_token differs from the one they're hydrating; in both
4777
+ // cases the guard fires only when storage was *modified between
4778
+ // snapshots*, not when the input token disagrees with what's stored.
4779
+ const storedAtStart = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4780
+
4677
4781
  const { data, error } = await this._refreshAccessToken(refreshToken)
4678
4782
  if (error) throw error
4679
4783
  if (!data.session) throw new AuthSessionMissingError()
4680
4784
 
4785
+ const storedAfter = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4786
+ const storageChangedUnderUs =
4787
+ storedAtStart !== null &&
4788
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token)
4789
+
4790
+ if (storageChangedUnderUs) {
4791
+ this._debug(
4792
+ debugName,
4793
+ 'commit guard: storage changed since refresh started, discarding rotated tokens',
4794
+ {
4795
+ // Presence indicators only — never log refresh token fragments,
4796
+ // even partial. Logs may be forwarded to third-party services.
4797
+ startedWith: 'present',
4798
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4799
+ }
4800
+ )
4801
+ const discarded: CallRefreshTokenResult = {
4802
+ data: null,
4803
+ error: new AuthRefreshDiscardedError(),
4804
+ }
4805
+ this.refreshingDeferred.resolve(discarded)
4806
+ return discarded
4807
+ }
4808
+
4809
+ // Second leg of the commit guard: close the TOCTOU window between the
4810
+ // synchronous `storageChangedUnderUs` check and the actual storage
4811
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4812
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4813
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4814
+ // the save and re-check after; if it advanced, undo the write directly
4815
+ // (do NOT call `_removeSession` — that would emit a duplicate
4816
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4817
+ const epochBeforeSave = this._sessionRemovalEpoch
4818
+
4681
4819
  await this._saveSession(data.session)
4820
+
4821
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4822
+ this._debug(
4823
+ debugName,
4824
+ 'commit guard (post-save): _removeSession ran during _saveSession, undoing write'
4825
+ )
4826
+ await removeItemAsync(this.storage, this.storageKey)
4827
+ if (this.userStorage) {
4828
+ await removeItemAsync(this.userStorage, this.storageKey + '-user')
4829
+ }
4830
+ const discarded: CallRefreshTokenResult = {
4831
+ data: null,
4832
+ error: new AuthRefreshDiscardedError(),
4833
+ }
4834
+ this.refreshingDeferred.resolve(discarded)
4835
+ return discarded
4836
+ }
4837
+
4682
4838
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4683
4839
 
4684
4840
  const result = { data: data.session, error: null }
@@ -4790,6 +4946,11 @@ export default class GoTrueClient {
4790
4946
  }
4791
4947
 
4792
4948
  private async _removeSession() {
4949
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
4950
+ // post-save check sees the increment whenever this method has started —
4951
+ // even if it hasn't finished. Pairs with the epoch check in
4952
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
4953
+ this._sessionRemovalEpoch += 1
4793
4954
  this._debug('#_removeSession()')
4794
4955
 
4795
4956
  this.suppressGetSessionWarning = false
@@ -4978,58 +5139,144 @@ export default class GoTrueClient {
4978
5139
  await this._stopAutoRefresh()
4979
5140
  }
4980
5141
 
5142
+ /**
5143
+ * Tears down the client's background work: stops the auto-refresh interval,
5144
+ * removes the `visibilitychange` listener, closes the cross-tab
5145
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
5146
+ *
5147
+ * Call this from cleanup hooks when the client is being replaced before
5148
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
5149
+ * cases. Any in-flight `fetch` calls continue to completion and may still
5150
+ * write to storage; dispose doesn't abort them or erase storage.
5151
+ *
5152
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
5153
+ * disposed instance can still persist a rotated session to storage after
5154
+ * `dispose()` returns. A subsequent `createClient` against the same
5155
+ * `storageKey` will pick up that session on its next read. If you need
5156
+ * strict isolation between client lifecycles, await any pending auth
5157
+ * operation before calling `dispose()` (or change the `storageKey` for
5158
+ * the replacement client).
5159
+ *
5160
+ * Safe to call repeatedly.
5161
+ *
5162
+ * @category Auth
5163
+ *
5164
+ * @example Cleanup on React unmount
5165
+ * ```ts
5166
+ * useEffect(() => {
5167
+ * const client = createClient(...)
5168
+ * return () => { client.auth.dispose() }
5169
+ * }, [])
5170
+ * ```
5171
+ */
5172
+ async dispose(): Promise<void> {
5173
+ this._removeVisibilityChangedCallback()
5174
+ await this._stopAutoRefresh()
5175
+ this.broadcastChannel?.close()
5176
+ this.broadcastChannel = null
5177
+ this.stateChangeEmitters.clear()
5178
+ }
5179
+
4981
5180
  /**
4982
5181
  * Runs the auto refresh token tick.
4983
5182
  */
4984
5183
  private async _autoRefreshTokenTick() {
4985
5184
  this._debug('#_autoRefreshTokenTick()', 'begin')
4986
5185
 
4987
- try {
4988
- await this._acquireLock(0, async () => {
4989
- try {
4990
- const now = Date.now()
4991
-
5186
+ if (this.lock != null) {
5187
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
5188
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
5189
+ // that's the fail-fast skip path that lets the tick bail out instead
5190
+ // of queuing behind a long-running operation.
5191
+ try {
5192
+ await this._acquireLock(0, async () => {
4992
5193
  try {
4993
- return await this._useSession(async (result) => {
4994
- const {
4995
- data: { session },
4996
- } = result
4997
-
4998
- if (!session || !session.refresh_token || !session.expires_at) {
4999
- this._debug('#_autoRefreshTokenTick()', 'no session')
5000
- return
5001
- }
5194
+ const now = Date.now()
5195
+ try {
5196
+ return await this._useSession(async (result) => {
5197
+ const {
5198
+ data: { session },
5199
+ } = result
5200
+
5201
+ if (!session || !session.refresh_token || !session.expires_at) {
5202
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5203
+ return
5204
+ }
5002
5205
 
5003
- // session will expire in this many ticks (or has already expired if <= 0)
5004
- const expiresInTicks = Math.floor(
5005
- (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5006
- )
5206
+ const expiresInTicks = Math.floor(
5207
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5208
+ )
5007
5209
 
5008
- this._debug(
5009
- '#_autoRefreshTokenTick()',
5010
- `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5011
- )
5210
+ this._debug(
5211
+ '#_autoRefreshTokenTick()',
5212
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5213
+ )
5012
5214
 
5013
- if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5014
- await this._callRefreshToken(session.refresh_token)
5015
- }
5016
- })
5017
- } catch (e) {
5018
- console.error(
5019
- 'Auto refresh tick failed with error. This is likely a transient error.',
5020
- e
5021
- )
5215
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5216
+ await this._callRefreshToken(session.refresh_token)
5217
+ }
5218
+ })
5219
+ } catch (e) {
5220
+ console.error(
5221
+ 'Auto refresh tick failed with error. This is likely a transient error.',
5222
+ e
5223
+ )
5224
+ }
5225
+ } finally {
5226
+ this._debug('#_autoRefreshTokenTick()', 'end')
5022
5227
  }
5023
- } finally {
5024
- this._debug('#_autoRefreshTokenTick()', 'end')
5228
+ })
5229
+ } catch (e) {
5230
+ if (e instanceof LockAcquireTimeoutError) {
5231
+ this._debug('auto refresh token tick lock not available')
5232
+ } else {
5233
+ throw e
5025
5234
  }
5026
- })
5027
- } catch (e) {
5028
- if (e instanceof LockAcquireTimeoutError) {
5029
- this._debug('auto refresh token tick lock not available')
5030
- } else {
5031
- throw e
5032
5235
  }
5236
+ return
5237
+ }
5238
+
5239
+ // Lockless default: skip if a refresh is already in flight.
5240
+ // `_callRefreshToken` also dedupes via the same field; this is just a
5241
+ // fast-path skip to avoid an unnecessary storage read.
5242
+ if (this.refreshingDeferred !== null) {
5243
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping')
5244
+ return
5245
+ }
5246
+
5247
+ try {
5248
+ const now = Date.now()
5249
+
5250
+ try {
5251
+ await this._useSession(async (result) => {
5252
+ const {
5253
+ data: { session },
5254
+ } = result
5255
+
5256
+ if (!session || !session.refresh_token || !session.expires_at) {
5257
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5258
+ return
5259
+ }
5260
+
5261
+ // session will expire in this many ticks (or has already expired if <= 0)
5262
+ const expiresInTicks = Math.floor(
5263
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5264
+ )
5265
+
5266
+ this._debug(
5267
+ '#_autoRefreshTokenTick()',
5268
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5269
+ )
5270
+
5271
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5272
+ await this._callRefreshToken(session.refresh_token)
5273
+ }
5274
+ })
5275
+ } catch (e) {
5276
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
5277
+ }
5278
+ } finally {
5279
+ this._debug('#_autoRefreshTokenTick()', 'end')
5033
5280
  }
5034
5281
  }
5035
5282
 
@@ -5086,24 +5333,29 @@ export default class GoTrueClient {
5086
5333
  if (!calledFromInitialize) {
5087
5334
  // called when the visibility has changed, i.e. the browser
5088
5335
  // transitioned from hidden -> visible so we need to see if the session
5089
- // should be recovered immediately... but to do that we need to acquire
5090
- // the lock first asynchronously
5336
+ // should be recovered
5091
5337
  await this.initializePromise
5092
5338
 
5093
- await this._acquireLock(this.lockAcquireTimeout, async () => {
5339
+ if (this.lock != null) {
5340
+ // TODO(v3): remove legacy lock path
5341
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
5342
+ if (document.visibilityState !== 'visible') {
5343
+ this._debug(
5344
+ methodName,
5345
+ 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5346
+ )
5347
+ return
5348
+ }
5349
+ await this._recoverAndRefresh()
5350
+ })
5351
+ } else {
5094
5352
  if (document.visibilityState !== 'visible') {
5095
- this._debug(
5096
- methodName,
5097
- 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5098
- )
5099
-
5100
- // visibility has changed while waiting for the lock, abort
5353
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
5101
5354
  return
5102
5355
  }
5103
-
5104
5356
  // recover the session
5105
5357
  await this._recoverAndRefresh()
5106
- })
5358
+ }
5107
5359
  }
5108
5360
  } else if (document.visibilityState === 'hidden') {
5109
5361
  if (this.autoRefreshToken) {
@@ -5235,7 +5487,7 @@ export default class GoTrueClient {
5235
5487
  params: MFAVerifyWebauthnParams<T>
5236
5488
  ): Promise<AuthMFAVerifyResponse>
5237
5489
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5238
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5490
+ const run = async (): Promise<AuthMFAVerifyResponse> => {
5239
5491
  try {
5240
5492
  return await this._useSession(async (result) => {
5241
5493
  const { data: sessionData, error: sessionError } = result
@@ -5306,7 +5558,13 @@ export default class GoTrueClient {
5306
5558
  }
5307
5559
  throw error
5308
5560
  }
5309
- })
5561
+ }
5562
+
5563
+ if (this.lock != null) {
5564
+ // TODO(v3): remove legacy lock path
5565
+ return this._acquireLock(this.lockAcquireTimeout, run)
5566
+ }
5567
+ return run()
5310
5568
  }
5311
5569
 
5312
5570
  /**
@@ -5322,7 +5580,7 @@ export default class GoTrueClient {
5322
5580
  params: MFAChallengeWebauthnParams
5323
5581
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5324
5582
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5325
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5583
+ const run = async (): Promise<AuthMFAChallengeResponse> => {
5326
5584
  try {
5327
5585
  return await this._useSession(async (result) => {
5328
5586
  const { data: sessionData, error: sessionError } = result
@@ -5395,7 +5653,13 @@ export default class GoTrueClient {
5395
5653
  }
5396
5654
  throw error
5397
5655
  }
5398
- })
5656
+ }
5657
+
5658
+ if (this.lock != null) {
5659
+ // TODO(v3): remove legacy lock path
5660
+ return this._acquireLock(this.lockAcquireTimeout, run)
5661
+ }
5662
+ return run()
5399
5663
  }
5400
5664
 
5401
5665
  /**
@@ -5404,9 +5668,6 @@ export default class GoTrueClient {
5404
5668
  private async _challengeAndVerify(
5405
5669
  params: MFAChallengeAndVerifyParams
5406
5670
  ): Promise<AuthMFAVerifyResponse> {
5407
- // both _challenge and _verify independently acquire the lock, so no need
5408
- // to acquire it here
5409
-
5410
5671
  const { data: challengeData, error: challengeError } = await this._challenge({
5411
5672
  factorId: params.factorId,
5412
5673
  })
@@ -5425,7 +5686,6 @@ export default class GoTrueClient {
5425
5686
  * {@see GoTrueMFAApi#listFactors}
5426
5687
  */
5427
5688
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5428
- // use #getUser instead of #_getUser as the former acquires a lock
5429
5689
  const {
5430
5690
  data: { user },
5431
5691
  error: userError,