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

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 (52) hide show
  1. package/dist/main/GoTrueAdminApi.d.ts +0 -1
  2. package/dist/main/GoTrueAdminApi.d.ts.map +1 -1
  3. package/dist/main/GoTrueAdminApi.js +7 -20
  4. package/dist/main/GoTrueAdminApi.js.map +1 -1
  5. package/dist/main/GoTrueClient.d.ts +83 -14
  6. package/dist/main/GoTrueClient.d.ts.map +1 -1
  7. package/dist/main/GoTrueClient.js +346 -107
  8. package/dist/main/GoTrueClient.js.map +1 -1
  9. package/dist/main/lib/errors.d.ts +24 -0
  10. package/dist/main/lib/errors.d.ts.map +1 -1
  11. package/dist/main/lib/errors.js +31 -1
  12. package/dist/main/lib/errors.js.map +1 -1
  13. package/dist/main/lib/locks.d.ts +28 -34
  14. package/dist/main/lib/locks.d.ts.map +1 -1
  15. package/dist/main/lib/locks.js +28 -34
  16. package/dist/main/lib/locks.js.map +1 -1
  17. package/dist/main/lib/types.d.ts +16 -27
  18. package/dist/main/lib/types.d.ts.map +1 -1
  19. package/dist/main/lib/types.js.map +1 -1
  20. package/dist/main/lib/version.d.ts +1 -1
  21. package/dist/main/lib/version.js +1 -1
  22. package/dist/module/GoTrueAdminApi.d.ts +0 -1
  23. package/dist/module/GoTrueAdminApi.d.ts.map +1 -1
  24. package/dist/module/GoTrueAdminApi.js +8 -21
  25. package/dist/module/GoTrueAdminApi.js.map +1 -1
  26. package/dist/module/GoTrueClient.d.ts +83 -14
  27. package/dist/module/GoTrueClient.d.ts.map +1 -1
  28. package/dist/module/GoTrueClient.js +348 -109
  29. package/dist/module/GoTrueClient.js.map +1 -1
  30. package/dist/module/lib/errors.d.ts +24 -0
  31. package/dist/module/lib/errors.d.ts.map +1 -1
  32. package/dist/module/lib/errors.js +28 -0
  33. package/dist/module/lib/errors.js.map +1 -1
  34. package/dist/module/lib/locks.d.ts +28 -34
  35. package/dist/module/lib/locks.d.ts.map +1 -1
  36. package/dist/module/lib/locks.js +28 -34
  37. package/dist/module/lib/locks.js.map +1 -1
  38. package/dist/module/lib/types.d.ts +16 -27
  39. package/dist/module/lib/types.d.ts.map +1 -1
  40. package/dist/module/lib/types.js.map +1 -1
  41. package/dist/module/lib/version.d.ts +1 -1
  42. package/dist/module/lib/version.js +1 -1
  43. package/dist/tsconfig.module.tsbuildinfo +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/migrations/lockless-coordination.md +89 -0
  46. package/package.json +1 -1
  47. package/src/GoTrueAdminApi.ts +32 -72
  48. package/src/GoTrueClient.ts +412 -137
  49. package/src/lib/errors.ts +32 -0
  50. package/src/lib/locks.ts +29 -34
  51. package/src/lib/types.ts +16 -27
  52. 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
@@ -1083,6 +1116,21 @@ export default class GoTrueClient {
1083
1116
  * password: 'some-password',
1084
1117
  * })
1085
1118
  * ```
1119
+ *
1120
+ * @exampleDescription Handling errors
1121
+ * Log the full `error` object so fields like `code`, `status`, and `name` aren't hidden. The `error.code` (e.g. `'invalid_credentials'`, `'email_not_confirmed'`) is often more useful for branching than `error.message`, and the full object surfaces both.
1122
+ *
1123
+ * @example Handling errors
1124
+ * ```js
1125
+ * const { data, error } = await supabase.auth.signInWithPassword({
1126
+ * email: 'example@email.com',
1127
+ * password: 'example-password',
1128
+ * })
1129
+ * if (error) {
1130
+ * console.error(error)
1131
+ * return
1132
+ * }
1133
+ * ```
1086
1134
  */
1087
1135
  async signInWithPassword(
1088
1136
  credentials: SignInWithPasswordCredentials
@@ -1405,9 +1453,14 @@ export default class GoTrueClient {
1405
1453
  async exchangeCodeForSession(authCode: string): Promise<AuthTokenResponse> {
1406
1454
  await this.initializePromise
1407
1455
 
1408
- return this._acquireLock(this.lockAcquireTimeout, async () => {
1409
- return this._exchangeCodeForSession(authCode)
1410
- })
1456
+ if (this.lock != null) {
1457
+ // TODO(v3): remove legacy lock path
1458
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1459
+ return this._exchangeCodeForSession(authCode)
1460
+ })
1461
+ }
1462
+
1463
+ return this._exchangeCodeForSession(authCode)
1411
1464
  }
1412
1465
 
1413
1466
  /**
@@ -2444,9 +2497,14 @@ export default class GoTrueClient {
2444
2497
  async reauthenticate(): Promise<AuthResponse> {
2445
2498
  await this.initializePromise
2446
2499
 
2447
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2448
- return await this._reauthenticate()
2449
- })
2500
+ if (this.lock != null) {
2501
+ // TODO(v3): remove legacy lock path
2502
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2503
+ return await this._reauthenticate()
2504
+ })
2505
+ }
2506
+
2507
+ return await this._reauthenticate()
2450
2508
  }
2451
2509
 
2452
2510
  private async _reauthenticate(): Promise<AuthResponse> {
@@ -2593,7 +2651,7 @@ export default class GoTrueClient {
2593
2651
  * - 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
2652
  * - 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
2653
  * - **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.
2654
+ * - 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
2655
  *
2598
2656
  * @example Get the session data
2599
2657
  * ```js
@@ -2661,17 +2719,26 @@ export default class GoTrueClient {
2661
2719
  async getSession() {
2662
2720
  await this.initializePromise
2663
2721
 
2664
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2665
- return this._useSession(async (result) => {
2666
- return result
2722
+ if (this.lock != null) {
2723
+ // TODO(v3): remove legacy lock path
2724
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2725
+ return this._useSession(async (result) => {
2726
+ return result
2727
+ })
2667
2728
  })
2668
- })
2729
+ }
2669
2730
 
2670
- return result
2731
+ return await this._useSession(async (result) => {
2732
+ return result
2733
+ })
2671
2734
  }
2672
2735
 
2673
2736
  /**
2674
2737
  * Acquires a global lock based on the storage key.
2738
+ *
2739
+ * TODO(v3): remove along with the legacy lock path. Only called when
2740
+ * `this.lock` is non-null (custom lock supplied via constructor). The
2741
+ * default lockless path bypasses this entirely.
2675
2742
  */
2676
2743
  private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
2677
2744
  this._debug('#_acquireLock', 'begin', acquireTimeout)
@@ -2700,7 +2767,7 @@ export default class GoTrueClient {
2700
2767
  return result
2701
2768
  }
2702
2769
 
2703
- return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
2770
+ return await this.lock!(`lock:${this.storageKey}`, acquireTimeout, async () => {
2704
2771
  this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
2705
2772
 
2706
2773
  try {
@@ -2742,10 +2809,9 @@ export default class GoTrueClient {
2742
2809
  }
2743
2810
 
2744
2811
  /**
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.
2812
+ * Use instead of {@link #getSession} inside the library. Loads the session
2813
+ * via `__loadSession` (which may trigger a refresh if the access token is
2814
+ * within the expiry margin) and runs `fn` with the result.
2749
2815
  */
2750
2816
  private async _useSession<R>(
2751
2817
  fn: (
@@ -2773,7 +2839,10 @@ export default class GoTrueClient {
2773
2839
  this._debug('#_useSession', 'begin')
2774
2840
 
2775
2841
  try {
2776
- // the use of __loadSession here is the only correct use of the function!
2842
+ // Concurrent callers may both reach __loadSession; storage reads are
2843
+ // idempotent, and the only write path inside it (refresh) is
2844
+ // single-flighted downstream by `refreshingDeferred` in
2845
+ // `_callRefreshToken`. No serialization is needed at this layer.
2777
2846
  const result = await this.__loadSession()
2778
2847
 
2779
2848
  return await fn(result)
@@ -2809,7 +2878,8 @@ export default class GoTrueClient {
2809
2878
  > {
2810
2879
  this._debug('#__loadSession()', 'begin')
2811
2880
 
2812
- if (!this.lockAcquired) {
2881
+ if (this.lock != null && !this.lockAcquired) {
2882
+ // TODO(v3): remove. Only meaningful on the legacy lock path.
2813
2883
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack)
2814
2884
  }
2815
2885
 
@@ -2976,9 +3046,15 @@ export default class GoTrueClient {
2976
3046
 
2977
3047
  await this.initializePromise
2978
3048
 
2979
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2980
- return await this._getUser()
2981
- })
3049
+ let result: UserResponse
3050
+ if (this.lock != null) {
3051
+ // TODO(v3): remove legacy lock path
3052
+ result = await this._acquireLock(this.lockAcquireTimeout, async () => {
3053
+ return await this._getUser()
3054
+ })
3055
+ } else {
3056
+ result = await this._getUser()
3057
+ }
2982
3058
 
2983
3059
  if (result.data.user) {
2984
3060
  this.suppressGetSessionWarning = true
@@ -3153,9 +3229,14 @@ export default class GoTrueClient {
3153
3229
  ): Promise<UserResponse> {
3154
3230
  await this.initializePromise
3155
3231
 
3156
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3157
- return await this._updateUser(attributes, options)
3158
- })
3232
+ if (this.lock != null) {
3233
+ // TODO(v3): remove legacy lock path
3234
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3235
+ return await this._updateUser(attributes, options)
3236
+ })
3237
+ }
3238
+
3239
+ return await this._updateUser(attributes, options)
3159
3240
  }
3160
3241
 
3161
3242
  protected async _updateUser(
@@ -3342,9 +3423,14 @@ export default class GoTrueClient {
3342
3423
  }): Promise<AuthResponse> {
3343
3424
  await this.initializePromise
3344
3425
 
3345
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3346
- return await this._setSession(currentSession)
3347
- })
3426
+ if (this.lock != null) {
3427
+ // TODO(v3): remove legacy lock path
3428
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3429
+ return await this._setSession(currentSession)
3430
+ })
3431
+ }
3432
+
3433
+ return await this._setSession(currentSession)
3348
3434
  }
3349
3435
 
3350
3436
  protected async _setSession(currentSession: {
@@ -3533,9 +3619,14 @@ export default class GoTrueClient {
3533
3619
  async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> {
3534
3620
  await this.initializePromise
3535
3621
 
3536
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3537
- return await this._refreshSession(currentSession)
3538
- })
3622
+ if (this.lock != null) {
3623
+ // TODO(v3): remove legacy lock path
3624
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3625
+ return await this._refreshSession(currentSession)
3626
+ })
3627
+ }
3628
+
3629
+ return await this._refreshSession(currentSession)
3539
3630
  }
3540
3631
 
3541
3632
  protected async _refreshSession(currentSession?: {
@@ -3785,9 +3876,14 @@ export default class GoTrueClient {
3785
3876
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3786
3877
  await this.initializePromise
3787
3878
 
3788
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3789
- return await this._signOut(options)
3790
- })
3879
+ if (this.lock != null) {
3880
+ // TODO(v3): remove legacy lock path
3881
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3882
+ return await this._signOut(options)
3883
+ })
3884
+ }
3885
+
3886
+ return await this._signOut(options)
3791
3887
  }
3792
3888
 
3793
3889
  protected async _signOut(
@@ -3834,16 +3930,19 @@ export default class GoTrueClient {
3834
3930
  }
3835
3931
 
3836
3932
  /**
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.
3933
+ * Receive a notification every time an auth event happens. Common reentry
3934
+ * patterns (`getUser`, `setSession`, reading the session from inside a
3935
+ * handler) complete normally. One hazard remains: calling `refreshSession`
3936
+ * (or anything that routes through `_callRefreshToken`) from inside a
3937
+ * `TOKEN_REFRESHED` handler. `refreshingDeferred` resolves only after
3938
+ * `_notifyAllSubscribers` returns, so the inner refresh dedupes onto the
3939
+ * outer's unresolved promise and the two wait on each other.
3844
3940
  *
3845
3941
  * @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.
3942
+ *
3943
+ * @deprecated Async callbacks can deadlock when they trigger a nested
3944
+ * refresh from a `TOKEN_REFRESHED` event. Prefer the sync overload, or move
3945
+ * refresh-triggering work outside the callback.
3847
3946
  */
3848
3947
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3849
3948
  data: { subscription: Subscription }
@@ -3856,18 +3955,8 @@ export default class GoTrueClient {
3856
3955
  * - Subscribes to important events occurring on the user's session.
3857
3956
  * - Use on the frontend/client. It is less useful on the server.
3858
3957
  * - 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
- * ```
3958
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
3959
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3871
3960
  * - Emitted events:
3872
3961
  * - `INITIAL_SESSION`
3873
3962
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -4058,9 +4147,14 @@ export default class GoTrueClient {
4058
4147
  ;(async () => {
4059
4148
  await this.initializePromise
4060
4149
 
4061
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4062
- this._emitInitialSession(id)
4063
- })
4150
+ if (this.lock != null) {
4151
+ // TODO(v3): remove legacy lock path
4152
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4153
+ this._emitInitialSession(id)
4154
+ })
4155
+ } else {
4156
+ await this._emitInitialSession(id)
4157
+ }
4064
4158
  })()
4065
4159
 
4066
4160
  return { data: { subscription } }
@@ -4455,7 +4549,10 @@ export default class GoTrueClient {
4455
4549
  * @param refreshToken A valid refresh token that was returned on login.
4456
4550
  */
4457
4551
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
4458
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
4552
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4553
+ // fragment of the token in the debug tag, even when `debug: true` is
4554
+ // enabled (logs may be forwarded to third-party services).
4555
+ const debugName = `#_refreshAccessToken()`
4459
4556
  this._debug(debugName, 'begin')
4460
4557
 
4461
4558
  try {
@@ -4608,15 +4705,22 @@ export default class GoTrueClient {
4608
4705
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4609
4706
 
4610
4707
  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()
4708
+ // AuthRefreshDiscardedError means a concurrent signOut already
4709
+ // cleared storage and fired SIGNED_OUT. Don't run _removeSession
4710
+ // again here, or we'll emit a duplicate SIGNED_OUT.
4711
+ if (isAuthRefreshDiscardedError(error)) {
4712
+ this._debug(debugName, 'refresh discarded by commit guard', error)
4713
+ } else {
4714
+ console.error(error)
4715
+
4716
+ if (!isAuthRetryableFetchError(error)) {
4717
+ this._debug(
4718
+ debugName,
4719
+ 'refresh failed with a non-retryable error, removing the session',
4720
+ error
4721
+ )
4722
+ await this._removeSession()
4723
+ }
4620
4724
  }
4621
4725
  }
4622
4726
  }
@@ -4669,18 +4773,85 @@ export default class GoTrueClient {
4669
4773
  return this.refreshingDeferred.promise
4670
4774
  }
4671
4775
 
4672
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
4776
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4777
+ // fragment of the token in the debug tag, even when `debug: true` is
4778
+ // enabled (logs may be forwarded to third-party services).
4779
+ const debugName = `#_callRefreshToken()`
4673
4780
 
4674
4781
  this._debug(debugName, 'begin')
4675
4782
 
4676
4783
  try {
4677
4784
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4678
4785
 
4786
+ // Snapshot storage before the fetch. The commit guard discards the
4787
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
4788
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
4789
+ // another tab's refresh rewrote the slot. Callers passing
4790
+ // externally-sourced tokens (SSR cookie handoff, multi-account
4791
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
4792
+ // start from a null snapshot OR from a non-null snapshot whose
4793
+ // refresh_token differs from the one they're hydrating; in both
4794
+ // cases the guard fires only when storage was *modified between
4795
+ // snapshots*, not when the input token disagrees with what's stored.
4796
+ const storedAtStart = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4797
+
4679
4798
  const { data, error } = await this._refreshAccessToken(refreshToken)
4680
4799
  if (error) throw error
4681
4800
  if (!data.session) throw new AuthSessionMissingError()
4682
4801
 
4802
+ const storedAfter = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4803
+ const storageChangedUnderUs =
4804
+ storedAtStart !== null &&
4805
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token)
4806
+
4807
+ if (storageChangedUnderUs) {
4808
+ this._debug(
4809
+ debugName,
4810
+ 'commit guard: storage changed since refresh started, discarding rotated tokens',
4811
+ {
4812
+ // Presence indicators only — never log refresh token fragments,
4813
+ // even partial. Logs may be forwarded to third-party services.
4814
+ startedWith: 'present',
4815
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4816
+ }
4817
+ )
4818
+ const discarded: CallRefreshTokenResult = {
4819
+ data: null,
4820
+ error: new AuthRefreshDiscardedError(),
4821
+ }
4822
+ this.refreshingDeferred.resolve(discarded)
4823
+ return discarded
4824
+ }
4825
+
4826
+ // Second leg of the commit guard: close the TOCTOU window between the
4827
+ // synchronous `storageChangedUnderUs` check and the actual storage
4828
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4829
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4830
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4831
+ // the save and re-check after; if it advanced, undo the write directly
4832
+ // (do NOT call `_removeSession` — that would emit a duplicate
4833
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4834
+ const epochBeforeSave = this._sessionRemovalEpoch
4835
+
4683
4836
  await this._saveSession(data.session)
4837
+
4838
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4839
+ this._debug(
4840
+ debugName,
4841
+ 'commit guard (post-save): _removeSession ran during _saveSession, undoing write'
4842
+ )
4843
+ await removeItemAsync(this.storage, this.storageKey)
4844
+ if (this.userStorage) {
4845
+ await removeItemAsync(this.userStorage, this.storageKey + '-user')
4846
+ }
4847
+ const discarded: CallRefreshTokenResult = {
4848
+ data: null,
4849
+ error: new AuthRefreshDiscardedError(),
4850
+ }
4851
+ this.refreshingDeferred.resolve(discarded)
4852
+ return discarded
4853
+ }
4854
+
4684
4855
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4685
4856
 
4686
4857
  const result = { data: data.session, error: null }
@@ -4792,6 +4963,11 @@ export default class GoTrueClient {
4792
4963
  }
4793
4964
 
4794
4965
  private async _removeSession() {
4966
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
4967
+ // post-save check sees the increment whenever this method has started —
4968
+ // even if it hasn't finished. Pairs with the epoch check in
4969
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
4970
+ this._sessionRemovalEpoch += 1
4795
4971
  this._debug('#_removeSession()')
4796
4972
 
4797
4973
  this.suppressGetSessionWarning = false
@@ -4980,58 +5156,144 @@ export default class GoTrueClient {
4980
5156
  await this._stopAutoRefresh()
4981
5157
  }
4982
5158
 
5159
+ /**
5160
+ * Tears down the client's background work: stops the auto-refresh interval,
5161
+ * removes the `visibilitychange` listener, closes the cross-tab
5162
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
5163
+ *
5164
+ * Call this from cleanup hooks when the client is being replaced before
5165
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
5166
+ * cases. Any in-flight `fetch` calls continue to completion and may still
5167
+ * write to storage; dispose doesn't abort them or erase storage.
5168
+ *
5169
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
5170
+ * disposed instance can still persist a rotated session to storage after
5171
+ * `dispose()` returns. A subsequent `createClient` against the same
5172
+ * `storageKey` will pick up that session on its next read. If you need
5173
+ * strict isolation between client lifecycles, await any pending auth
5174
+ * operation before calling `dispose()` (or change the `storageKey` for
5175
+ * the replacement client).
5176
+ *
5177
+ * Safe to call repeatedly.
5178
+ *
5179
+ * @category Auth
5180
+ *
5181
+ * @example Cleanup on React unmount
5182
+ * ```ts
5183
+ * useEffect(() => {
5184
+ * const client = createClient(...)
5185
+ * return () => { client.auth.dispose() }
5186
+ * }, [])
5187
+ * ```
5188
+ */
5189
+ async dispose(): Promise<void> {
5190
+ this._removeVisibilityChangedCallback()
5191
+ await this._stopAutoRefresh()
5192
+ this.broadcastChannel?.close()
5193
+ this.broadcastChannel = null
5194
+ this.stateChangeEmitters.clear()
5195
+ }
5196
+
4983
5197
  /**
4984
5198
  * Runs the auto refresh token tick.
4985
5199
  */
4986
5200
  private async _autoRefreshTokenTick() {
4987
5201
  this._debug('#_autoRefreshTokenTick()', 'begin')
4988
5202
 
4989
- try {
4990
- await this._acquireLock(0, async () => {
4991
- try {
4992
- const now = Date.now()
4993
-
5203
+ if (this.lock != null) {
5204
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
5205
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
5206
+ // that's the fail-fast skip path that lets the tick bail out instead
5207
+ // of queuing behind a long-running operation.
5208
+ try {
5209
+ await this._acquireLock(0, async () => {
4994
5210
  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
- }
5211
+ const now = Date.now()
5212
+ try {
5213
+ return await this._useSession(async (result) => {
5214
+ const {
5215
+ data: { session },
5216
+ } = result
5217
+
5218
+ if (!session || !session.refresh_token || !session.expires_at) {
5219
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5220
+ return
5221
+ }
5004
5222
 
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
- )
5223
+ const expiresInTicks = Math.floor(
5224
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5225
+ )
5009
5226
 
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
- )
5227
+ this._debug(
5228
+ '#_autoRefreshTokenTick()',
5229
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5230
+ )
5014
5231
 
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
- )
5232
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5233
+ await this._callRefreshToken(session.refresh_token)
5234
+ }
5235
+ })
5236
+ } catch (e) {
5237
+ console.error(
5238
+ 'Auto refresh tick failed with error. This is likely a transient error.',
5239
+ e
5240
+ )
5241
+ }
5242
+ } finally {
5243
+ this._debug('#_autoRefreshTokenTick()', 'end')
5024
5244
  }
5025
- } finally {
5026
- this._debug('#_autoRefreshTokenTick()', 'end')
5245
+ })
5246
+ } catch (e) {
5247
+ if (e instanceof LockAcquireTimeoutError) {
5248
+ this._debug('auto refresh token tick lock not available')
5249
+ } else {
5250
+ throw e
5027
5251
  }
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
5252
  }
5253
+ return
5254
+ }
5255
+
5256
+ // Lockless default: skip if a refresh is already in flight.
5257
+ // `_callRefreshToken` also dedupes via the same field; this is just a
5258
+ // fast-path skip to avoid an unnecessary storage read.
5259
+ if (this.refreshingDeferred !== null) {
5260
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping')
5261
+ return
5262
+ }
5263
+
5264
+ try {
5265
+ const now = Date.now()
5266
+
5267
+ try {
5268
+ await this._useSession(async (result) => {
5269
+ const {
5270
+ data: { session },
5271
+ } = result
5272
+
5273
+ if (!session || !session.refresh_token || !session.expires_at) {
5274
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5275
+ return
5276
+ }
5277
+
5278
+ // session will expire in this many ticks (or has already expired if <= 0)
5279
+ const expiresInTicks = Math.floor(
5280
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5281
+ )
5282
+
5283
+ this._debug(
5284
+ '#_autoRefreshTokenTick()',
5285
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5286
+ )
5287
+
5288
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5289
+ await this._callRefreshToken(session.refresh_token)
5290
+ }
5291
+ })
5292
+ } catch (e) {
5293
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
5294
+ }
5295
+ } finally {
5296
+ this._debug('#_autoRefreshTokenTick()', 'end')
5035
5297
  }
5036
5298
  }
5037
5299
 
@@ -5088,24 +5350,29 @@ export default class GoTrueClient {
5088
5350
  if (!calledFromInitialize) {
5089
5351
  // called when the visibility has changed, i.e. the browser
5090
5352
  // 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
5353
+ // should be recovered
5093
5354
  await this.initializePromise
5094
5355
 
5095
- await this._acquireLock(this.lockAcquireTimeout, async () => {
5356
+ if (this.lock != null) {
5357
+ // TODO(v3): remove legacy lock path
5358
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
5359
+ if (document.visibilityState !== 'visible') {
5360
+ this._debug(
5361
+ methodName,
5362
+ 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5363
+ )
5364
+ return
5365
+ }
5366
+ await this._recoverAndRefresh()
5367
+ })
5368
+ } else {
5096
5369
  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
5370
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
5103
5371
  return
5104
5372
  }
5105
-
5106
5373
  // recover the session
5107
5374
  await this._recoverAndRefresh()
5108
- })
5375
+ }
5109
5376
  }
5110
5377
  } else if (document.visibilityState === 'hidden') {
5111
5378
  if (this.autoRefreshToken) {
@@ -5237,7 +5504,7 @@ export default class GoTrueClient {
5237
5504
  params: MFAVerifyWebauthnParams<T>
5238
5505
  ): Promise<AuthMFAVerifyResponse>
5239
5506
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5240
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5507
+ const run = async (): Promise<AuthMFAVerifyResponse> => {
5241
5508
  try {
5242
5509
  return await this._useSession(async (result) => {
5243
5510
  const { data: sessionData, error: sessionError } = result
@@ -5308,7 +5575,13 @@ export default class GoTrueClient {
5308
5575
  }
5309
5576
  throw error
5310
5577
  }
5311
- })
5578
+ }
5579
+
5580
+ if (this.lock != null) {
5581
+ // TODO(v3): remove legacy lock path
5582
+ return this._acquireLock(this.lockAcquireTimeout, run)
5583
+ }
5584
+ return run()
5312
5585
  }
5313
5586
 
5314
5587
  /**
@@ -5324,7 +5597,7 @@ export default class GoTrueClient {
5324
5597
  params: MFAChallengeWebauthnParams
5325
5598
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5326
5599
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5327
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5600
+ const run = async (): Promise<AuthMFAChallengeResponse> => {
5328
5601
  try {
5329
5602
  return await this._useSession(async (result) => {
5330
5603
  const { data: sessionData, error: sessionError } = result
@@ -5397,7 +5670,13 @@ export default class GoTrueClient {
5397
5670
  }
5398
5671
  throw error
5399
5672
  }
5400
- })
5673
+ }
5674
+
5675
+ if (this.lock != null) {
5676
+ // TODO(v3): remove legacy lock path
5677
+ return this._acquireLock(this.lockAcquireTimeout, run)
5678
+ }
5679
+ return run()
5401
5680
  }
5402
5681
 
5403
5682
  /**
@@ -5406,9 +5685,6 @@ export default class GoTrueClient {
5406
5685
  private async _challengeAndVerify(
5407
5686
  params: MFAChallengeAndVerifyParams
5408
5687
  ): Promise<AuthMFAVerifyResponse> {
5409
- // both _challenge and _verify independently acquire the lock, so no need
5410
- // to acquire it here
5411
-
5412
5688
  const { data: challengeData, error: challengeError } = await this._challenge({
5413
5689
  factorId: params.factorId,
5414
5690
  })
@@ -5427,7 +5703,6 @@ export default class GoTrueClient {
5427
5703
  * {@see GoTrueMFAApi#listFactors}
5428
5704
  */
5429
5705
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5430
- // use #getUser instead of #_getUser as the former acquires a lock
5431
5706
  const {
5432
5707
  data: { user },
5433
5708
  error: userError,