@supabase/auth-js 2.107.0-beta.1 → 2.107.0-canary.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 (47) hide show
  1. package/dist/main/GoTrueClient.d.ts +14 -68
  2. package/dist/main/GoTrueClient.d.ts.map +1 -1
  3. package/dist/main/GoTrueClient.js +116 -334
  4. package/dist/main/GoTrueClient.js.map +1 -1
  5. package/dist/main/lib/errors.d.ts +0 -24
  6. package/dist/main/lib/errors.d.ts.map +1 -1
  7. package/dist/main/lib/errors.js +1 -31
  8. package/dist/main/lib/errors.js.map +1 -1
  9. package/dist/main/lib/locks.d.ts +34 -28
  10. package/dist/main/lib/locks.d.ts.map +1 -1
  11. package/dist/main/lib/locks.js +34 -28
  12. package/dist/main/lib/locks.js.map +1 -1
  13. package/dist/main/lib/types.d.ts +27 -16
  14. package/dist/main/lib/types.d.ts.map +1 -1
  15. package/dist/main/lib/types.js.map +1 -1
  16. package/dist/main/lib/version.d.ts +1 -1
  17. package/dist/main/lib/version.d.ts.map +1 -1
  18. package/dist/main/lib/version.js +1 -1
  19. package/dist/main/lib/version.js.map +1 -1
  20. package/dist/module/GoTrueClient.d.ts +14 -68
  21. package/dist/module/GoTrueClient.d.ts.map +1 -1
  22. package/dist/module/GoTrueClient.js +118 -336
  23. package/dist/module/GoTrueClient.js.map +1 -1
  24. package/dist/module/lib/errors.d.ts +0 -24
  25. package/dist/module/lib/errors.d.ts.map +1 -1
  26. package/dist/module/lib/errors.js +0 -28
  27. package/dist/module/lib/errors.js.map +1 -1
  28. package/dist/module/lib/locks.d.ts +34 -28
  29. package/dist/module/lib/locks.d.ts.map +1 -1
  30. package/dist/module/lib/locks.js +34 -28
  31. package/dist/module/lib/locks.js.map +1 -1
  32. package/dist/module/lib/types.d.ts +27 -16
  33. package/dist/module/lib/types.d.ts.map +1 -1
  34. package/dist/module/lib/types.js.map +1 -1
  35. package/dist/module/lib/version.d.ts +1 -1
  36. package/dist/module/lib/version.d.ts.map +1 -1
  37. package/dist/module/lib/version.js +1 -1
  38. package/dist/module/lib/version.js.map +1 -1
  39. package/dist/tsconfig.module.tsbuildinfo +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +1 -1
  42. package/src/GoTrueClient.ts +147 -400
  43. package/src/lib/errors.ts +0 -32
  44. package/src/lib/locks.ts +34 -29
  45. package/src/lib/types.ts +27 -16
  46. package/src/lib/version.ts +1 -1
  47. package/migrations/lockless-coordination.md +0 -89
@@ -16,13 +16,11 @@ import {
16
16
  AuthInvalidTokenResponseError,
17
17
  AuthPKCECodeVerifierMissingError,
18
18
  AuthPKCEGrantCodeExchangeError,
19
- AuthRefreshDiscardedError,
20
19
  AuthSessionMissingError,
21
20
  AuthUnknownError,
22
21
  isAuthApiError,
23
22
  isAuthError,
24
23
  isAuthImplicitGrantRedirectError,
25
- isAuthRefreshDiscardedError,
26
24
  isAuthRetryableFetchError,
27
25
  isAuthSessionMissingError,
28
26
  } from './lib/errors'
@@ -197,17 +195,11 @@ const DEFAULT_OPTIONS: Omit<
197
195
  debug: false,
198
196
  hasCustomAuthorizationHeader: false,
199
197
  throwOnError: false,
200
- lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
198
+ lockAcquireTimeout: 5000, // 5 seconds
201
199
  skipAutoInitialize: false,
202
200
  experimental: {},
203
201
  }
204
202
 
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
- */
211
203
  async function lockNoOp<R>(name: string, acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
212
204
  return await fn()
213
205
  }
@@ -288,14 +280,6 @@ export default class GoTrueClient {
288
280
  protected autoRefreshTickTimeout: ReturnType<typeof setTimeout> | null = null
289
281
  protected visibilityChangedCallback: (() => Promise<any>) | null = null
290
282
  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
299
283
  /**
300
284
  * Keeps track of the async client initialization.
301
285
  * When null or not yet resolved the auth state is `unknown`
@@ -313,19 +297,10 @@ export default class GoTrueClient {
313
297
  protected hasCustomAuthorizationHeader = false
314
298
  protected suppressGetSessionWarning = false
315
299
  protected fetch: Fetch
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
300
+ protected lock: LockFunc
323
301
  protected lockAcquired = false
324
302
  protected pendingInLock: Promise<any>[] = []
325
303
  protected throwOnError: boolean
326
- /**
327
- * Only consulted when a custom `lock` is supplied. TODO(v3): remove.
328
- */
329
304
  protected lockAcquireTimeout: number
330
305
  /**
331
306
  * Opt-in flags for experimental features. Defaults to an empty object.
@@ -396,23 +371,19 @@ export default class GoTrueClient {
396
371
  this.url = settings.url
397
372
  this.headers = settings.headers
398
373
  this.fetch = resolveFetch(settings.fetch)
374
+ this.lock = settings.lock || lockNoOp
399
375
  this.detectSessionInUrl = settings.detectSessionInUrl
400
376
  this.flowType = settings.flowType
401
377
  this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader
402
378
  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.
407
379
  this.lockAcquireTimeout = settings.lockAcquireTimeout
408
380
 
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) {
381
+ if (settings.lock) {
415
382
  this.lock = settings.lock
383
+ } else if (this.persistSession && isBrowser() && globalThis?.navigator?.locks) {
384
+ this.lock = navigatorLock
385
+ } else {
386
+ this.lock = lockNoOp
416
387
  }
417
388
 
418
389
  if (!this.jwks) {
@@ -547,13 +518,9 @@ export default class GoTrueClient {
547
518
  }
548
519
 
549
520
  this.initializePromise = (async () => {
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()
521
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
522
+ return await this._initialize()
523
+ })
557
524
  })()
558
525
 
559
526
  return await this.initializePromise
@@ -1438,14 +1405,9 @@ export default class GoTrueClient {
1438
1405
  async exchangeCodeForSession(authCode: string): Promise<AuthTokenResponse> {
1439
1406
  await this.initializePromise
1440
1407
 
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)
1408
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1409
+ return this._exchangeCodeForSession(authCode)
1410
+ })
1449
1411
  }
1450
1412
 
1451
1413
  /**
@@ -2482,14 +2444,9 @@ export default class GoTrueClient {
2482
2444
  async reauthenticate(): Promise<AuthResponse> {
2483
2445
  await this.initializePromise
2484
2446
 
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()
2447
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2448
+ return await this._reauthenticate()
2449
+ })
2493
2450
  }
2494
2451
 
2495
2452
  private async _reauthenticate(): Promise<AuthResponse> {
@@ -2636,7 +2593,7 @@ export default class GoTrueClient {
2636
2593
  * - If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
2637
2594
  * - 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.
2638
2595
  * - **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.
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.
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.
2640
2597
  *
2641
2598
  * @example Get the session data
2642
2599
  * ```js
@@ -2704,26 +2661,17 @@ export default class GoTrueClient {
2704
2661
  async getSession() {
2705
2662
  await this.initializePromise
2706
2663
 
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
- })
2664
+ const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2665
+ return this._useSession(async (result) => {
2666
+ return result
2713
2667
  })
2714
- }
2715
-
2716
- return await this._useSession(async (result) => {
2717
- return result
2718
2668
  })
2669
+
2670
+ return result
2719
2671
  }
2720
2672
 
2721
2673
  /**
2722
2674
  * 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.
2727
2675
  */
2728
2676
  private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
2729
2677
  this._debug('#_acquireLock', 'begin', acquireTimeout)
@@ -2752,7 +2700,7 @@ export default class GoTrueClient {
2752
2700
  return result
2753
2701
  }
2754
2702
 
2755
- return await this.lock!(`lock:${this.storageKey}`, acquireTimeout, async () => {
2703
+ return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
2756
2704
  this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
2757
2705
 
2758
2706
  try {
@@ -2794,9 +2742,10 @@ export default class GoTrueClient {
2794
2742
  }
2795
2743
 
2796
2744
  /**
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.
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.
2800
2749
  */
2801
2750
  private async _useSession<R>(
2802
2751
  fn: (
@@ -2824,10 +2773,7 @@ export default class GoTrueClient {
2824
2773
  this._debug('#_useSession', 'begin')
2825
2774
 
2826
2775
  try {
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.
2776
+ // the use of __loadSession here is the only correct use of the function!
2831
2777
  const result = await this.__loadSession()
2832
2778
 
2833
2779
  return await fn(result)
@@ -2863,8 +2809,7 @@ export default class GoTrueClient {
2863
2809
  > {
2864
2810
  this._debug('#__loadSession()', 'begin')
2865
2811
 
2866
- if (this.lock != null && !this.lockAcquired) {
2867
- // TODO(v3): remove. Only meaningful on the legacy lock path.
2812
+ if (!this.lockAcquired) {
2868
2813
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack)
2869
2814
  }
2870
2815
 
@@ -3031,15 +2976,9 @@ export default class GoTrueClient {
3031
2976
 
3032
2977
  await this.initializePromise
3033
2978
 
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
- }
2979
+ const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2980
+ return await this._getUser()
2981
+ })
3043
2982
 
3044
2983
  if (result.data.user) {
3045
2984
  this.suppressGetSessionWarning = true
@@ -3214,14 +3153,9 @@ export default class GoTrueClient {
3214
3153
  ): Promise<UserResponse> {
3215
3154
  await this.initializePromise
3216
3155
 
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)
3156
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3157
+ return await this._updateUser(attributes, options)
3158
+ })
3225
3159
  }
3226
3160
 
3227
3161
  protected async _updateUser(
@@ -3408,14 +3342,9 @@ export default class GoTrueClient {
3408
3342
  }): Promise<AuthResponse> {
3409
3343
  await this.initializePromise
3410
3344
 
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)
3345
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3346
+ return await this._setSession(currentSession)
3347
+ })
3419
3348
  }
3420
3349
 
3421
3350
  protected async _setSession(currentSession: {
@@ -3604,14 +3533,9 @@ export default class GoTrueClient {
3604
3533
  async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> {
3605
3534
  await this.initializePromise
3606
3535
 
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)
3536
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3537
+ return await this._refreshSession(currentSession)
3538
+ })
3615
3539
  }
3616
3540
 
3617
3541
  protected async _refreshSession(currentSession?: {
@@ -3800,7 +3724,9 @@ export default class GoTrueClient {
3800
3724
  if (typeof this.detectSessionInUrl === 'function') {
3801
3725
  return this.detectSessionInUrl(new URL(window.location.href), params)
3802
3726
  }
3803
- return Boolean(params.access_token || params.error_description)
3727
+ return Boolean(
3728
+ params.access_token || params.error || params.error_description || params.error_code
3729
+ )
3804
3730
  }
3805
3731
 
3806
3732
  /**
@@ -3859,14 +3785,9 @@ export default class GoTrueClient {
3859
3785
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3860
3786
  await this.initializePromise
3861
3787
 
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)
3788
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3789
+ return await this._signOut(options)
3790
+ })
3870
3791
  }
3871
3792
 
3872
3793
  protected async _signOut(
@@ -3913,19 +3834,16 @@ export default class GoTrueClient {
3913
3834
  }
3914
3835
 
3915
3836
  /**
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.
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.
3923
3842
  *
3924
- * @param callback A callback function to be invoked when an auth event happens.
3843
+ * Receive a notification every time an auth event happens.
3925
3844
  *
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
+ * @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.
3929
3847
  */
3930
3848
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3931
3849
  data: { subscription: Subscription }
@@ -3938,8 +3856,18 @@ export default class GoTrueClient {
3938
3856
  * - Subscribes to important events occurring on the user's session.
3939
3857
  * - Use on the frontend/client. It is less useful on the server.
3940
3858
  * - 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.
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.
3859
+ * - **Important:** A callback can be an `async` function and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by using `await` on a call to another method of the Supabase library.
3860
+ * - Avoid using `async` functions as callbacks.
3861
+ * - Limit the number of `await` calls in `async` callbacks.
3862
+ * - Do not use other Supabase functions in the callback function. If you must, dispatch the functions once the callback has finished executing. Use this as a quick way to achieve this:
3863
+ * ```js
3864
+ * supabase.auth.onAuthStateChange((event, session) => {
3865
+ * setTimeout(async () => {
3866
+ * // await on other Supabase function here
3867
+ * // this runs right after the callback has finished
3868
+ * }, 0)
3869
+ * })
3870
+ * ```
3943
3871
  * - Emitted events:
3944
3872
  * - `INITIAL_SESSION`
3945
3873
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -4130,14 +4058,9 @@ export default class GoTrueClient {
4130
4058
  ;(async () => {
4131
4059
  await this.initializePromise
4132
4060
 
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
- }
4061
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4062
+ this._emitInitialSession(id)
4063
+ })
4141
4064
  })()
4142
4065
 
4143
4066
  return { data: { subscription } }
@@ -4532,10 +4455,7 @@ export default class GoTrueClient {
4532
4455
  * @param refreshToken A valid refresh token that was returned on login.
4533
4456
  */
4534
4457
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
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()`
4458
+ const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
4539
4459
  this._debug(debugName, 'begin')
4540
4460
 
4541
4461
  try {
@@ -4688,22 +4608,15 @@ export default class GoTrueClient {
4688
4608
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4689
4609
 
4690
4610
  if (error) {
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
- }
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()
4707
4620
  }
4708
4621
  }
4709
4622
  }
@@ -4756,85 +4669,18 @@ export default class GoTrueClient {
4756
4669
  return this.refreshingDeferred.promise
4757
4670
  }
4758
4671
 
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()`
4672
+ const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
4763
4673
 
4764
4674
  this._debug(debugName, 'begin')
4765
4675
 
4766
4676
  try {
4767
4677
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4768
4678
 
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
-
4781
4679
  const { data, error } = await this._refreshAccessToken(refreshToken)
4782
4680
  if (error) throw error
4783
4681
  if (!data.session) throw new AuthSessionMissingError()
4784
4682
 
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
-
4819
4683
  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
-
4838
4684
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4839
4685
 
4840
4686
  const result = { data: data.session, error: null }
@@ -4946,11 +4792,6 @@ export default class GoTrueClient {
4946
4792
  }
4947
4793
 
4948
4794
  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
4954
4795
  this._debug('#_removeSession()')
4955
4796
 
4956
4797
  this.suppressGetSessionWarning = false
@@ -5139,144 +4980,58 @@ export default class GoTrueClient {
5139
4980
  await this._stopAutoRefresh()
5140
4981
  }
5141
4982
 
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
-
5180
4983
  /**
5181
4984
  * Runs the auto refresh token tick.
5182
4985
  */
5183
4986
  private async _autoRefreshTokenTick() {
5184
4987
  this._debug('#_autoRefreshTokenTick()', 'begin')
5185
4988
 
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 () => {
5193
- try {
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
- }
5205
-
5206
- const expiresInTicks = Math.floor(
5207
- (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5208
- )
5209
-
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
- )
5214
-
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')
5227
- }
5228
- })
5229
- } catch (e) {
5230
- if (e instanceof LockAcquireTimeoutError) {
5231
- this._debug('auto refresh token tick lock not available')
5232
- } else {
5233
- throw e
5234
- }
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
4989
  try {
5248
- const now = Date.now()
5249
-
5250
- try {
5251
- await this._useSession(async (result) => {
5252
- const {
5253
- data: { session },
5254
- } = result
4990
+ await this._acquireLock(0, async () => {
4991
+ try {
4992
+ const now = Date.now()
5255
4993
 
5256
- if (!session || !session.refresh_token || !session.expires_at) {
5257
- this._debug('#_autoRefreshTokenTick()', 'no session')
5258
- return
5259
- }
4994
+ 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
+ }
5260
5004
 
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
- )
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
+ )
5265
5009
 
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
- )
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
+ )
5270
5014
 
5271
- if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5272
- await this._callRefreshToken(session.refresh_token)
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
+ )
5273
5024
  }
5274
- })
5275
- } catch (e) {
5276
- console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
5025
+ } finally {
5026
+ this._debug('#_autoRefreshTokenTick()', 'end')
5027
+ }
5028
+ })
5029
+ } catch (e) {
5030
+ if (e instanceof LockAcquireTimeoutError) {
5031
+ this._debug('auto refresh token tick lock not available')
5032
+ } else {
5033
+ throw e
5277
5034
  }
5278
- } finally {
5279
- this._debug('#_autoRefreshTokenTick()', 'end')
5280
5035
  }
5281
5036
  }
5282
5037
 
@@ -5333,29 +5088,24 @@ export default class GoTrueClient {
5333
5088
  if (!calledFromInitialize) {
5334
5089
  // called when the visibility has changed, i.e. the browser
5335
5090
  // transitioned from hidden -> visible so we need to see if the session
5336
- // should be recovered
5091
+ // should be recovered immediately... but to do that we need to acquire
5092
+ // the lock first asynchronously
5337
5093
  await this.initializePromise
5338
5094
 
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 {
5095
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
5352
5096
  if (document.visibilityState !== 'visible') {
5353
- this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
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
5354
5103
  return
5355
5104
  }
5105
+
5356
5106
  // recover the session
5357
5107
  await this._recoverAndRefresh()
5358
- }
5108
+ })
5359
5109
  }
5360
5110
  } else if (document.visibilityState === 'hidden') {
5361
5111
  if (this.autoRefreshToken) {
@@ -5487,7 +5237,7 @@ export default class GoTrueClient {
5487
5237
  params: MFAVerifyWebauthnParams<T>
5488
5238
  ): Promise<AuthMFAVerifyResponse>
5489
5239
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5490
- const run = async (): Promise<AuthMFAVerifyResponse> => {
5240
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
5491
5241
  try {
5492
5242
  return await this._useSession(async (result) => {
5493
5243
  const { data: sessionData, error: sessionError } = result
@@ -5558,13 +5308,7 @@ export default class GoTrueClient {
5558
5308
  }
5559
5309
  throw error
5560
5310
  }
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()
5311
+ })
5568
5312
  }
5569
5313
 
5570
5314
  /**
@@ -5580,7 +5324,7 @@ export default class GoTrueClient {
5580
5324
  params: MFAChallengeWebauthnParams
5581
5325
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5582
5326
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5583
- const run = async (): Promise<AuthMFAChallengeResponse> => {
5327
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
5584
5328
  try {
5585
5329
  return await this._useSession(async (result) => {
5586
5330
  const { data: sessionData, error: sessionError } = result
@@ -5653,13 +5397,7 @@ export default class GoTrueClient {
5653
5397
  }
5654
5398
  throw error
5655
5399
  }
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()
5400
+ })
5663
5401
  }
5664
5402
 
5665
5403
  /**
@@ -5668,6 +5406,9 @@ export default class GoTrueClient {
5668
5406
  private async _challengeAndVerify(
5669
5407
  params: MFAChallengeAndVerifyParams
5670
5408
  ): Promise<AuthMFAVerifyResponse> {
5409
+ // both _challenge and _verify independently acquire the lock, so no need
5410
+ // to acquire it here
5411
+
5671
5412
  const { data: challengeData, error: challengeError } = await this._challenge({
5672
5413
  factorId: params.factorId,
5673
5414
  })
@@ -5686,6 +5427,7 @@ export default class GoTrueClient {
5686
5427
  * {@see GoTrueMFAApi#listFactors}
5687
5428
  */
5688
5429
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5430
+ // use #getUser instead of #_getUser as the former acquires a lock
5689
5431
  const {
5690
5432
  data: { user },
5691
5433
  error: userError,
@@ -6165,8 +5907,13 @@ export default class GoTrueClient {
6165
5907
  } = decodeJWT(token)
6166
5908
 
6167
5909
  if (!options?.allowExpired) {
6168
- // Reject expired JWTs should only happen if jwt argument was passed
6169
- validateExp(payload.exp)
5910
+ // Reject expired JWTs should only happen if jwt argument was passed.
5911
+ // Rethrow as AuthInvalidJwtError so the outer catch converts it to { data, error }.
5912
+ try {
5913
+ validateExp(payload.exp)
5914
+ } catch (e) {
5915
+ throw new AuthInvalidJwtError(e instanceof Error ? e.message : 'JWT validation failed')
5916
+ }
6170
5917
  }
6171
5918
 
6172
5919
  const signingKey =