@supabase/gotrue-js 2.105.4 → 2.107.0-canary.5

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 (57) hide show
  1. package/AGENTS.md +11 -0
  2. package/README.md +19 -19
  3. package/dist/main/GoTrueClient.d.ts +83 -14
  4. package/dist/main/GoTrueClient.d.ts.map +1 -1
  5. package/dist/main/GoTrueClient.js +355 -110
  6. package/dist/main/GoTrueClient.js.map +1 -1
  7. package/dist/main/lib/errors.d.ts +24 -0
  8. package/dist/main/lib/errors.d.ts.map +1 -1
  9. package/dist/main/lib/errors.js +31 -1
  10. package/dist/main/lib/errors.js.map +1 -1
  11. package/dist/main/lib/fetch.d.ts.map +1 -1
  12. package/dist/main/lib/fetch.js +3 -1
  13. package/dist/main/lib/fetch.js.map +1 -1
  14. package/dist/main/lib/locks.d.ts +28 -34
  15. package/dist/main/lib/locks.d.ts.map +1 -1
  16. package/dist/main/lib/locks.js +28 -34
  17. package/dist/main/lib/locks.js.map +1 -1
  18. package/dist/main/lib/types.d.ts +16 -27
  19. package/dist/main/lib/types.d.ts.map +1 -1
  20. package/dist/main/lib/types.js.map +1 -1
  21. package/dist/main/lib/version.d.ts +1 -1
  22. package/dist/main/lib/version.d.ts.map +1 -1
  23. package/dist/main/lib/version.js +1 -1
  24. package/dist/main/lib/version.js.map +1 -1
  25. package/dist/module/GoTrueClient.d.ts +83 -14
  26. package/dist/module/GoTrueClient.d.ts.map +1 -1
  27. package/dist/module/GoTrueClient.js +357 -112
  28. package/dist/module/GoTrueClient.js.map +1 -1
  29. package/dist/module/lib/errors.d.ts +24 -0
  30. package/dist/module/lib/errors.d.ts.map +1 -1
  31. package/dist/module/lib/errors.js +28 -0
  32. package/dist/module/lib/errors.js.map +1 -1
  33. package/dist/module/lib/fetch.d.ts.map +1 -1
  34. package/dist/module/lib/fetch.js +3 -1
  35. package/dist/module/lib/fetch.js.map +1 -1
  36. package/dist/module/lib/locks.d.ts +28 -34
  37. package/dist/module/lib/locks.d.ts.map +1 -1
  38. package/dist/module/lib/locks.js +28 -34
  39. package/dist/module/lib/locks.js.map +1 -1
  40. package/dist/module/lib/types.d.ts +16 -27
  41. package/dist/module/lib/types.d.ts.map +1 -1
  42. package/dist/module/lib/types.js.map +1 -1
  43. package/dist/module/lib/version.d.ts +1 -1
  44. package/dist/module/lib/version.d.ts.map +1 -1
  45. package/dist/module/lib/version.js +1 -1
  46. package/dist/module/lib/version.js.map +1 -1
  47. package/dist/tsconfig.module.tsbuildinfo +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/migrations/README.md +25 -0
  50. package/migrations/lockless-coordination.md +89 -0
  51. package/package.json +24 -11
  52. package/src/GoTrueClient.ts +423 -141
  53. package/src/lib/errors.ts +32 -0
  54. package/src/lib/fetch.ts +3 -1
  55. package/src/lib/locks.ts +29 -34
  56. package/src/lib/types.ts +16 -27
  57. 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
  /**
@@ -2305,7 +2358,7 @@ export default class GoTrueClient {
2305
2358
  }
2306
2359
 
2307
2360
  const session: Session | null = data.session
2308
- const user: User = data.user
2361
+ const user: User | null = data.user
2309
2362
 
2310
2363
  if (session?.access_token) {
2311
2364
  await this._saveSession(session as Session)
@@ -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?: {
@@ -3724,7 +3815,9 @@ export default class GoTrueClient {
3724
3815
  if (typeof this.detectSessionInUrl === 'function') {
3725
3816
  return this.detectSessionInUrl(new URL(window.location.href), params)
3726
3817
  }
3727
- return Boolean(params.access_token || params.error_description)
3818
+ return Boolean(
3819
+ params.access_token || params.error || params.error_description || params.error_code
3820
+ )
3728
3821
  }
3729
3822
 
3730
3823
  /**
@@ -3783,9 +3876,14 @@ export default class GoTrueClient {
3783
3876
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3784
3877
  await this.initializePromise
3785
3878
 
3786
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3787
- return await this._signOut(options)
3788
- })
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)
3789
3887
  }
3790
3888
 
3791
3889
  protected async _signOut(
@@ -3832,16 +3930,19 @@ export default class GoTrueClient {
3832
3930
  }
3833
3931
 
3834
3932
  /**
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.
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.
3842
3940
  *
3843
3941
  * @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.
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.
3845
3946
  */
3846
3947
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3847
3948
  data: { subscription: Subscription }
@@ -3854,18 +3955,8 @@ export default class GoTrueClient {
3854
3955
  * - Subscribes to important events occurring on the user's session.
3855
3956
  * - Use on the frontend/client. It is less useful on the server.
3856
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.
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
- * ```
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.
3869
3960
  * - Emitted events:
3870
3961
  * - `INITIAL_SESSION`
3871
3962
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -4056,9 +4147,14 @@ export default class GoTrueClient {
4056
4147
  ;(async () => {
4057
4148
  await this.initializePromise
4058
4149
 
4059
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4060
- this._emitInitialSession(id)
4061
- })
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
+ }
4062
4158
  })()
4063
4159
 
4064
4160
  return { data: { subscription } }
@@ -4453,7 +4549,10 @@ export default class GoTrueClient {
4453
4549
  * @param refreshToken A valid refresh token that was returned on login.
4454
4550
  */
4455
4551
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
4456
- 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()`
4457
4556
  this._debug(debugName, 'begin')
4458
4557
 
4459
4558
  try {
@@ -4606,15 +4705,22 @@ export default class GoTrueClient {
4606
4705
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4607
4706
 
4608
4707
  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()
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
+ }
4618
4724
  }
4619
4725
  }
4620
4726
  }
@@ -4667,18 +4773,85 @@ export default class GoTrueClient {
4667
4773
  return this.refreshingDeferred.promise
4668
4774
  }
4669
4775
 
4670
- 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()`
4671
4780
 
4672
4781
  this._debug(debugName, 'begin')
4673
4782
 
4674
4783
  try {
4675
4784
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4676
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
+
4677
4798
  const { data, error } = await this._refreshAccessToken(refreshToken)
4678
4799
  if (error) throw error
4679
4800
  if (!data.session) throw new AuthSessionMissingError()
4680
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
+
4681
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
+
4682
4855
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4683
4856
 
4684
4857
  const result = { data: data.session, error: null }
@@ -4790,6 +4963,11 @@ export default class GoTrueClient {
4790
4963
  }
4791
4964
 
4792
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
4793
4971
  this._debug('#_removeSession()')
4794
4972
 
4795
4973
  this.suppressGetSessionWarning = false
@@ -4978,58 +5156,144 @@ export default class GoTrueClient {
4978
5156
  await this._stopAutoRefresh()
4979
5157
  }
4980
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
+
4981
5197
  /**
4982
5198
  * Runs the auto refresh token tick.
4983
5199
  */
4984
5200
  private async _autoRefreshTokenTick() {
4985
5201
  this._debug('#_autoRefreshTokenTick()', 'begin')
4986
5202
 
4987
- try {
4988
- await this._acquireLock(0, async () => {
4989
- try {
4990
- const now = Date.now()
4991
-
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 () => {
4992
5210
  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
- }
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
+ }
5002
5222
 
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
- )
5223
+ const expiresInTicks = Math.floor(
5224
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5225
+ )
5007
5226
 
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
- )
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
+ )
5012
5231
 
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
- )
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')
5022
5244
  }
5023
- } finally {
5024
- 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
5025
5251
  }
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
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')
5033
5297
  }
5034
5298
  }
5035
5299
 
@@ -5086,24 +5350,29 @@ export default class GoTrueClient {
5086
5350
  if (!calledFromInitialize) {
5087
5351
  // called when the visibility has changed, i.e. the browser
5088
5352
  // 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
5353
+ // should be recovered
5091
5354
  await this.initializePromise
5092
5355
 
5093
- 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 {
5094
5369
  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
5370
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
5101
5371
  return
5102
5372
  }
5103
-
5104
5373
  // recover the session
5105
5374
  await this._recoverAndRefresh()
5106
- })
5375
+ }
5107
5376
  }
5108
5377
  } else if (document.visibilityState === 'hidden') {
5109
5378
  if (this.autoRefreshToken) {
@@ -5235,7 +5504,7 @@ export default class GoTrueClient {
5235
5504
  params: MFAVerifyWebauthnParams<T>
5236
5505
  ): Promise<AuthMFAVerifyResponse>
5237
5506
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5238
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5507
+ const run = async (): Promise<AuthMFAVerifyResponse> => {
5239
5508
  try {
5240
5509
  return await this._useSession(async (result) => {
5241
5510
  const { data: sessionData, error: sessionError } = result
@@ -5306,7 +5575,13 @@ export default class GoTrueClient {
5306
5575
  }
5307
5576
  throw error
5308
5577
  }
5309
- })
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()
5310
5585
  }
5311
5586
 
5312
5587
  /**
@@ -5322,7 +5597,7 @@ export default class GoTrueClient {
5322
5597
  params: MFAChallengeWebauthnParams
5323
5598
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5324
5599
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5325
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5600
+ const run = async (): Promise<AuthMFAChallengeResponse> => {
5326
5601
  try {
5327
5602
  return await this._useSession(async (result) => {
5328
5603
  const { data: sessionData, error: sessionError } = result
@@ -5395,7 +5670,13 @@ export default class GoTrueClient {
5395
5670
  }
5396
5671
  throw error
5397
5672
  }
5398
- })
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()
5399
5680
  }
5400
5681
 
5401
5682
  /**
@@ -5404,9 +5685,6 @@ export default class GoTrueClient {
5404
5685
  private async _challengeAndVerify(
5405
5686
  params: MFAChallengeAndVerifyParams
5406
5687
  ): Promise<AuthMFAVerifyResponse> {
5407
- // both _challenge and _verify independently acquire the lock, so no need
5408
- // to acquire it here
5409
-
5410
5688
  const { data: challengeData, error: challengeError } = await this._challenge({
5411
5689
  factorId: params.factorId,
5412
5690
  })
@@ -5425,7 +5703,6 @@ export default class GoTrueClient {
5425
5703
  * {@see GoTrueMFAApi#listFactors}
5426
5704
  */
5427
5705
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5428
- // use #getUser instead of #_getUser as the former acquires a lock
5429
5706
  const {
5430
5707
  data: { user },
5431
5708
  error: userError,
@@ -5905,8 +6182,13 @@ export default class GoTrueClient {
5905
6182
  } = decodeJWT(token)
5906
6183
 
5907
6184
  if (!options?.allowExpired) {
5908
- // Reject expired JWTs should only happen if jwt argument was passed
5909
- validateExp(payload.exp)
6185
+ // Reject expired JWTs should only happen if jwt argument was passed.
6186
+ // Rethrow as AuthInvalidJwtError so the outer catch converts it to { data, error }.
6187
+ try {
6188
+ validateExp(payload.exp)
6189
+ } catch (e) {
6190
+ throw new AuthInvalidJwtError(e instanceof Error ? e.message : 'JWT validation failed')
6191
+ }
5910
6192
  }
5911
6193
 
5912
6194
  const signingKey =