@depup/supabase__auth-js 2.103.0-depup.0 → 2.110.8-depup.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 (106) hide show
  1. package/AGENTS.md +11 -0
  2. package/README.md +2 -2
  3. package/changes.json +1 -1
  4. package/dist/main/GoTrueAdminApi.d.ts +45 -8
  5. package/dist/main/GoTrueAdminApi.d.ts.map +1 -1
  6. package/dist/main/GoTrueAdminApi.js +67 -7
  7. package/dist/main/GoTrueAdminApi.js.map +1 -1
  8. package/dist/main/GoTrueClient.d.ts +239 -42
  9. package/dist/main/GoTrueClient.d.ts.map +1 -1
  10. package/dist/main/GoTrueClient.js +946 -164
  11. package/dist/main/GoTrueClient.js.map +1 -1
  12. package/dist/main/lib/constants.d.ts +8 -0
  13. package/dist/main/lib/constants.d.ts.map +1 -1
  14. package/dist/main/lib/constants.js +9 -1
  15. package/dist/main/lib/constants.js.map +1 -1
  16. package/dist/main/lib/errors.d.ts +42 -3
  17. package/dist/main/lib/errors.d.ts.map +1 -1
  18. package/dist/main/lib/errors.js +44 -13
  19. package/dist/main/lib/errors.js.map +1 -1
  20. package/dist/main/lib/fetch.d.ts +28 -8
  21. package/dist/main/lib/fetch.d.ts.map +1 -1
  22. package/dist/main/lib/fetch.js +28 -6
  23. package/dist/main/lib/fetch.js.map +1 -1
  24. package/dist/main/lib/helpers.d.ts +4 -1
  25. package/dist/main/lib/helpers.d.ts.map +1 -1
  26. package/dist/main/lib/helpers.js +15 -4
  27. package/dist/main/lib/helpers.js.map +1 -1
  28. package/dist/main/lib/locks.d.ts +29 -35
  29. package/dist/main/lib/locks.d.ts.map +1 -1
  30. package/dist/main/lib/locks.js +37 -38
  31. package/dist/main/lib/locks.js.map +1 -1
  32. package/dist/main/lib/types.d.ts +305 -64
  33. package/dist/main/lib/types.d.ts.map +1 -1
  34. package/dist/main/lib/types.js.map +1 -1
  35. package/dist/main/lib/version.d.ts +1 -1
  36. package/dist/main/lib/version.js +1 -1
  37. package/dist/main/lib/webauthn.d.ts +8 -0
  38. package/dist/main/lib/webauthn.d.ts.map +1 -1
  39. package/dist/main/lib/webauthn.dom.d.ts +3 -3
  40. package/dist/main/lib/webauthn.dom.d.ts.map +1 -1
  41. package/dist/main/lib/webauthn.errors.d.ts +5 -0
  42. package/dist/main/lib/webauthn.errors.d.ts.map +1 -1
  43. package/dist/main/lib/webauthn.errors.js +7 -0
  44. package/dist/main/lib/webauthn.errors.js.map +1 -1
  45. package/dist/main/lib/webauthn.js +1 -0
  46. package/dist/main/lib/webauthn.js.map +1 -1
  47. package/dist/module/GoTrueAdminApi.d.ts +45 -8
  48. package/dist/module/GoTrueAdminApi.d.ts.map +1 -1
  49. package/dist/module/GoTrueAdminApi.js +68 -8
  50. package/dist/module/GoTrueAdminApi.js.map +1 -1
  51. package/dist/module/GoTrueClient.d.ts +239 -42
  52. package/dist/module/GoTrueClient.d.ts.map +1 -1
  53. package/dist/module/GoTrueClient.js +951 -169
  54. package/dist/module/GoTrueClient.js.map +1 -1
  55. package/dist/module/lib/constants.d.ts +8 -0
  56. package/dist/module/lib/constants.d.ts.map +1 -1
  57. package/dist/module/lib/constants.js +8 -0
  58. package/dist/module/lib/constants.js.map +1 -1
  59. package/dist/module/lib/errors.d.ts +42 -3
  60. package/dist/module/lib/errors.d.ts.map +1 -1
  61. package/dist/module/lib/errors.js +41 -12
  62. package/dist/module/lib/errors.js.map +1 -1
  63. package/dist/module/lib/fetch.d.ts +28 -8
  64. package/dist/module/lib/fetch.d.ts.map +1 -1
  65. package/dist/module/lib/fetch.js +28 -6
  66. package/dist/module/lib/fetch.js.map +1 -1
  67. package/dist/module/lib/helpers.d.ts +4 -1
  68. package/dist/module/lib/helpers.d.ts.map +1 -1
  69. package/dist/module/lib/helpers.js +14 -4
  70. package/dist/module/lib/helpers.js.map +1 -1
  71. package/dist/module/lib/locks.d.ts +29 -35
  72. package/dist/module/lib/locks.d.ts.map +1 -1
  73. package/dist/module/lib/locks.js +37 -38
  74. package/dist/module/lib/locks.js.map +1 -1
  75. package/dist/module/lib/types.d.ts +305 -64
  76. package/dist/module/lib/types.d.ts.map +1 -1
  77. package/dist/module/lib/types.js.map +1 -1
  78. package/dist/module/lib/version.d.ts +1 -1
  79. package/dist/module/lib/version.js +1 -1
  80. package/dist/module/lib/webauthn.d.ts +8 -0
  81. package/dist/module/lib/webauthn.d.ts.map +1 -1
  82. package/dist/module/lib/webauthn.dom.d.ts +3 -3
  83. package/dist/module/lib/webauthn.dom.d.ts.map +1 -1
  84. package/dist/module/lib/webauthn.errors.d.ts +5 -0
  85. package/dist/module/lib/webauthn.errors.d.ts.map +1 -1
  86. package/dist/module/lib/webauthn.errors.js +7 -0
  87. package/dist/module/lib/webauthn.errors.js.map +1 -1
  88. package/dist/module/lib/webauthn.js +1 -1
  89. package/dist/module/lib/webauthn.js.map +1 -1
  90. package/dist/tsconfig.module.tsbuildinfo +1 -1
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/migrations/README.md +25 -0
  93. package/migrations/lockless-coordination.md +155 -0
  94. package/package.json +25 -12
  95. package/src/GoTrueAdminApi.ts +99 -7
  96. package/src/GoTrueClient.ts +1117 -181
  97. package/src/lib/constants.ts +9 -0
  98. package/src/lib/errors.ts +76 -9
  99. package/src/lib/fetch.ts +66 -24
  100. package/src/lib/helpers.ts +19 -6
  101. package/src/lib/locks.ts +44 -42
  102. package/src/lib/types.ts +374 -82
  103. package/src/lib/version.ts +1 -1
  104. package/src/lib/webauthn.dom.ts +3 -3
  105. package/src/lib/webauthn.errors.ts +12 -0
  106. package/src/lib/webauthn.ts +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  EXPIRY_MARGIN_MS,
7
7
  GOTRUE_URL,
8
8
  JWKS_TTL,
9
+ REFRESH_FAILURE_COOLDOWN_MS,
9
10
  STORAGE_KEY,
10
11
  } from './lib/constants'
11
12
  import {
@@ -16,11 +17,13 @@ import {
16
17
  AuthInvalidTokenResponseError,
17
18
  AuthPKCECodeVerifierMissingError,
18
19
  AuthPKCEGrantCodeExchangeError,
20
+ AuthRefreshDiscardedError,
19
21
  AuthSessionMissingError,
20
22
  AuthUnknownError,
21
23
  isAuthApiError,
22
24
  isAuthError,
23
25
  isAuthImplicitGrantRedirectError,
26
+ isAuthRefreshDiscardedError,
24
27
  isAuthRetryableFetchError,
25
28
  isAuthSessionMissingError,
26
29
  } from './lib/errors'
@@ -33,6 +36,7 @@ import {
33
36
  _userResponse,
34
37
  } from './lib/fetch'
35
38
  import {
39
+ assertPasskeyExperimentalEnabled,
36
40
  decodeJWT,
37
41
  deepClone,
38
42
  Deferred,
@@ -136,6 +140,22 @@ import type {
136
140
  UserResponse,
137
141
  VerifyOtpParams,
138
142
  Web3Credentials,
143
+ AuthPasskeyApi,
144
+ ExperimentalFeatureFlags,
145
+ SignInWithPasskeyCredentials,
146
+ RegisterPasskeyCredentials,
147
+ VerifyPasskeyRegistrationParams,
148
+ StartPasskeyAuthenticationParams,
149
+ VerifyPasskeyAuthenticationParams,
150
+ PasskeyUpdateParams,
151
+ PasskeyDeleteParams,
152
+ AuthPasskeyRegistrationOptionsResponse,
153
+ AuthPasskeyRegistrationVerifyResponse,
154
+ AuthPasskeyAuthenticationOptionsResponse,
155
+ AuthPasskeyAuthenticationVerifyResponse,
156
+ AuthPasskeyListResponse,
157
+ AuthPasskeyUpdateResponse,
158
+ AuthPasskeyDeleteResponse,
139
159
  } from './lib/types'
140
160
  import {
141
161
  createSiweMessage,
@@ -146,10 +166,14 @@ import {
146
166
  toHex,
147
167
  } from './lib/web3/ethereum'
148
168
  import {
169
+ createCredential,
149
170
  deserializeCredentialCreationOptions,
150
171
  deserializeCredentialRequestOptions,
172
+ getCredential,
151
173
  serializeCredentialCreationResponse,
152
174
  serializeCredentialRequestResponse,
175
+ browserSupportsWebAuthn,
176
+ webAuthnAbortService,
153
177
  WebAuthnApi,
154
178
  } from './lib/webauthn'
155
179
  import {
@@ -174,10 +198,17 @@ const DEFAULT_OPTIONS: Omit<
174
198
  debug: false,
175
199
  hasCustomAuthorizationHeader: false,
176
200
  throwOnError: false,
177
- lockAcquireTimeout: 5000, // 5 seconds
201
+ lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
178
202
  skipAutoInitialize: false,
203
+ experimental: {},
179
204
  }
180
205
 
206
+ /**
207
+ * No-op lock used internally as a placeholder. Kept so older test setups that
208
+ * inject this exact reference do not break; new code never sees it because
209
+ * `this.lock` stays `null` when no custom lock is supplied (lockless path).
210
+ * TODO(v3): remove with the legacy lock path.
211
+ */
181
212
  async function lockNoOp<R>(name: string, acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
182
213
  return await fn()
183
214
  }
@@ -212,6 +243,13 @@ export default class GoTrueClient {
212
243
  * Used to implement the authorization code flow on the consent page.
213
244
  */
214
245
  oauth: AuthOAuthServerApi
246
+ /**
247
+ * Namespace for passkey methods.
248
+ * Includes lower-level two-step registration/authentication and passkey management.
249
+ *
250
+ * Requires `auth.experimental.passkey: true`; otherwise all methods throw.
251
+ */
252
+ passkey: AuthPasskeyApi
215
253
  /**
216
254
  * The storage key used to identify the values saved in localStorage
217
255
  */
@@ -251,6 +289,33 @@ export default class GoTrueClient {
251
289
  protected autoRefreshTickTimeout: ReturnType<typeof setTimeout> | null = null
252
290
  protected visibilityChangedCallback: (() => Promise<any>) | null = null
253
291
  protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null
292
+ /**
293
+ * Cache of the most recent refresh failure, keyed by the refresh token
294
+ * that failed. Serial callers passing the *same* token within
295
+ * `REFRESH_FAILURE_COOLDOWN_MS` (including subsequent auto-refresh ticks)
296
+ * receive this cached result instead of firing another `/token` request.
297
+ * Callers passing a *different* token (token rotation pickup, explicit
298
+ * `setSession`/`refreshSession({ refresh_token })`, multi-account switch)
299
+ * bypass the cache and attempt a fresh refresh as they should.
300
+ * Cleared on any successful refresh (locally or via BroadcastChannel from
301
+ * another tab) and on `_removeSession`.
302
+ *
303
+ * Pairs with `refreshingDeferred`: concurrent callers share the in-flight
304
+ * promise, serial callers within the cooldown share the failure result.
305
+ */
306
+ protected lastRefreshFailure: {
307
+ refreshToken: string
308
+ result: CallRefreshTokenResult
309
+ expiresAt: number
310
+ } | null = null
311
+ /**
312
+ * Monotonic counter incremented at the top of `_removeSession`, before any
313
+ * `await`. The commit guard inside `_callRefreshToken` captures this value
314
+ * before `_saveSession` and re-checks it after, so a `signOut` that
315
+ * interleaves inside `_saveSession`'s storage-write awaits is still caught
316
+ * (the post-fetch storage snapshot alone misses that window).
317
+ */
318
+ protected _sessionRemovalEpoch = 0
254
319
  /**
255
320
  * Keeps track of the async client initialization.
256
321
  * When null or not yet resolved the auth state is `unknown`
@@ -258,6 +323,23 @@ export default class GoTrueClient {
258
323
  * Keep extra care to never reject or throw uncaught errors
259
324
  */
260
325
  protected initializePromise: Promise<InitializeResult> | null = null
326
+ /**
327
+ * Non-null only while `initialize()` is running. While open,
328
+ * `_notifyAllSubscribers` enqueues init-chain notifications (those fired with
329
+ * `broadcast = true`, i.e. from `_recoverAndRefresh`) into this array instead
330
+ * of firing directly, so that `initializePromise` is guaranteed to be
331
+ * resolved before any subscriber callback runs. Callbacks that call
332
+ * `getSession()` / `getUser()` etc. would otherwise deadlock because those
333
+ * methods await `initializePromise`. Notifications from the incoming
334
+ * BroadcastChannel handler (`broadcast = false`) are not enqueued — they fire
335
+ * immediately. Flushed (in order) by `initialize()` after `initializePromise`
336
+ * settles.
337
+ */
338
+ private _pendingInitNotifications: Array<{
339
+ event: AuthChangeEvent
340
+ session: Session | null
341
+ broadcast: boolean
342
+ }> | null = null
261
343
  protected detectSessionInUrl:
262
344
  | boolean
263
345
  | ((url: URL, params: { [parameter: string]: string }) => boolean) = true
@@ -268,11 +350,25 @@ export default class GoTrueClient {
268
350
  protected hasCustomAuthorizationHeader = false
269
351
  protected suppressGetSessionWarning = false
270
352
  protected fetch: Fetch
271
- protected lock: LockFunc
353
+ /**
354
+ * Custom lock function passed via `settings.lock`. When non-null, every auth
355
+ * operation runs inside `_acquireLock`. When null (the default), the client
356
+ * uses its lockless coordination (refresh single-flight + commit guard).
357
+ * TODO(v3): remove along with the legacy lock path.
358
+ */
359
+ protected lock: LockFunc | null = null
272
360
  protected lockAcquired = false
273
361
  protected pendingInLock: Promise<any>[] = []
274
362
  protected throwOnError: boolean
363
+ /**
364
+ * Only consulted when a custom `lock` is supplied. TODO(v3): remove.
365
+ */
275
366
  protected lockAcquireTimeout: number
367
+ /**
368
+ * Opt-in flags for experimental features. Defaults to an empty object.
369
+ * See `GoTrueClientOptions.experimental`.
370
+ */
371
+ protected experimental: ExperimentalFeatureFlags
276
372
 
277
373
  /**
278
374
  * Used to broadcast state change events to other tabs listening.
@@ -285,13 +381,21 @@ export default class GoTrueClient {
285
381
  /**
286
382
  * Create a new client for use in the browser.
287
383
  *
288
- * @example
384
+ * @example Using supabase-js (recommended)
385
+ * ```ts
386
+ * import { createClient } from '@supabase/supabase-js'
387
+ *
388
+ * const supabase = createClient('https://xyzcompany.supabase.co', 'your-publishable-key')
389
+ * const { data, error } = await supabase.auth.getUser()
390
+ * ```
391
+ *
392
+ * @example Standalone import for bundle-sensitive environments
289
393
  * ```ts
290
394
  * import { GoTrueClient } from '@supabase/auth-js'
291
395
  *
292
396
  * const auth = new GoTrueClient({
293
397
  * url: 'https://xyzcompany.supabase.co/auth/v1',
294
- * headers: { apikey: 'public-anon-key' },
398
+ * headers: { apikey: 'your-publishable-key' },
295
399
  * storageKey: 'supabase-auth',
296
400
  * })
297
401
  * ```
@@ -318,28 +422,34 @@ export default class GoTrueClient {
318
422
 
319
423
  this.persistSession = settings.persistSession
320
424
  this.autoRefreshToken = settings.autoRefreshToken
425
+ this.experimental = settings.experimental ?? {}
321
426
  this.admin = new GoTrueAdminApi({
322
427
  url: settings.url,
323
428
  headers: settings.headers,
324
429
  fetch: settings.fetch,
430
+ experimental: this.experimental,
325
431
  })
326
432
 
327
433
  this.url = settings.url
328
434
  this.headers = settings.headers
329
435
  this.fetch = resolveFetch(settings.fetch)
330
- this.lock = settings.lock || lockNoOp
331
436
  this.detectSessionInUrl = settings.detectSessionInUrl
332
437
  this.flowType = settings.flowType
333
438
  this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader
334
439
  this.throwOnError = settings.throwOnError
440
+
441
+ // Always wire `lockAcquireTimeout` even on the lockless path: consumers
442
+ // (including supabase-js tests) read it off the client to verify option
443
+ // flow-through.
335
444
  this.lockAcquireTimeout = settings.lockAcquireTimeout
336
445
 
337
- if (settings.lock) {
446
+ // TODO(v3): remove. Legacy opt-in path preserved for backwards
447
+ // compatibility with callers passing a custom `lock` (typically React
448
+ // Native `processLock` or Node multi-process setups). When `settings.lock`
449
+ // is null the client uses its lockless coordination — no `navigator.locks`
450
+ // by default, no implicit `processLock`.
451
+ if (settings.lock != null) {
338
452
  this.lock = settings.lock
339
- } else if (this.persistSession && isBrowser() && globalThis?.navigator?.locks) {
340
- this.lock = navigatorLock
341
- } else {
342
- this.lock = lockNoOp
343
453
  }
344
454
 
345
455
  if (!this.jwks) {
@@ -366,6 +476,16 @@ export default class GoTrueClient {
366
476
  revokeGrant: this._revokeOAuthGrant.bind(this),
367
477
  }
368
478
 
479
+ this.passkey = {
480
+ startRegistration: this._startPasskeyRegistration.bind(this),
481
+ verifyRegistration: this._verifyPasskeyRegistration.bind(this),
482
+ startAuthentication: this._startPasskeyAuthentication.bind(this),
483
+ verifyAuthentication: this._verifyPasskeyAuthentication.bind(this),
484
+ list: this._listPasskeys.bind(this),
485
+ update: this._updatePasskey.bind(this),
486
+ delete: this._deletePasskey.bind(this),
487
+ }
488
+
369
489
  if (this.persistSession) {
370
490
  if (settings.storage) {
371
491
  this.storage = settings.storage
@@ -389,7 +509,7 @@ export default class GoTrueClient {
389
509
  if (isBrowser() && globalThis.BroadcastChannel && this.persistSession && this.storageKey) {
390
510
  try {
391
511
  this.broadcastChannel = new globalThis.BroadcastChannel(this.storageKey)
392
- } catch (e: any) {
512
+ } catch (e) {
393
513
  console.error(
394
514
  'Failed to create a new BroadcastChannel, multi-tab state changes will not be available',
395
515
  e
@@ -399,6 +519,13 @@ export default class GoTrueClient {
399
519
  this.broadcastChannel?.addEventListener('message', async (event) => {
400
520
  this._debug('received broadcast notification from other tab or client', event)
401
521
 
522
+ // Another tab successfully refreshed or signed in — any cached
523
+ // failure in this tab is stale and should not block the next
524
+ // refresh attempt.
525
+ if (event.data.event === 'TOKEN_REFRESHED' || event.data.event === 'SIGNED_IN') {
526
+ this.lastRefreshFailure = null
527
+ }
528
+
402
529
  try {
403
530
  await this._notifyAllSubscribers(event.data.event, event.data.session, false) // broadcast = false so we don't get an endless loop of messages
404
531
  } catch (error) {
@@ -452,9 +579,20 @@ export default class GoTrueClient {
452
579
  }
453
580
 
454
581
  /**
455
- * Initializes the client session either from the url or from storage.
456
- * This method is automatically called when instantiating the client, but should also be called
457
- * manually when checking for an error from an auth redirect (oauth, magiclink, password recovery, etc).
582
+ * Initialize the auth client by loading the session from storage or
583
+ * detecting it from the URL after an OAuth, magic-link, or password-recovery
584
+ * redirect.
585
+ *
586
+ * **Most callers do not need to invoke this directly.** The client calls it
587
+ * automatically during construction, and to react to sign-in events (including
588
+ * post-redirect events) you should subscribe to `onAuthStateChange` rather
589
+ * than awaiting `initialize()`.
590
+ *
591
+ * You only need to call it manually when you have opted out of the automatic
592
+ * call by passing `skipAutoInitialize: true` — for example, in an SSR context
593
+ * where you need to control initialization timing. In that case, awaiting
594
+ * `initialize()` returns the resolved session result (or any error encountered
595
+ * while detecting it from the URL).
458
596
  *
459
597
  * @category Auth
460
598
  */
@@ -463,13 +601,37 @@ export default class GoTrueClient {
463
601
  return await this.initializePromise
464
602
  }
465
603
 
604
+ // Open the notification queue before _initialize() runs so that every
605
+ // _notifyAllSubscribers call inside the init chain enqueues instead of
606
+ // firing. Without this, a callback receiving SIGNED_IN (or TOKEN_REFRESHED
607
+ // / SIGNED_OUT) during _recoverAndRefresh would deadlock if it called
608
+ // getSession() / getUser() — those methods await initializePromise, which
609
+ // can only resolve after the callback returns, which can only return after
610
+ // getSession() resolves. The queue is flushed below after initializePromise
611
+ // has settled, so callbacks run with a fully resolved initializePromise.
612
+ this._pendingInitNotifications = []
613
+
466
614
  this.initializePromise = (async () => {
467
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
468
- return await this._initialize()
469
- })
615
+ if (this.lock != null) {
616
+ // TODO(v3): remove legacy lock path
617
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
618
+ return await this._initialize()
619
+ })
620
+ }
621
+ return await this._initialize()
470
622
  })()
471
623
 
472
- return await this.initializePromise
624
+ const result = await this.initializePromise
625
+
626
+ // initializePromise is now resolved — flush queued notifications in order.
627
+ // Callbacks can safely call getSession() / getUser() / signOut() etc.
628
+ const queue = this._pendingInitNotifications ?? []
629
+ this._pendingInitNotifications = null
630
+ for (const n of queue) {
631
+ await this._notifyAllSubscribers(n.event, n.session, n.broadcast)
632
+ }
633
+
634
+ return result
473
635
  }
474
636
 
475
637
  /**
@@ -1029,6 +1191,21 @@ export default class GoTrueClient {
1029
1191
  * password: 'some-password',
1030
1192
  * })
1031
1193
  * ```
1194
+ *
1195
+ * @exampleDescription Handling errors
1196
+ * 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.
1197
+ *
1198
+ * @example Handling errors
1199
+ * ```js
1200
+ * const { data, error } = await supabase.auth.signInWithPassword({
1201
+ * email: 'example@email.com',
1202
+ * password: 'example-password',
1203
+ * })
1204
+ * if (error) {
1205
+ * console.error(error)
1206
+ * return
1207
+ * }
1208
+ * ```
1032
1209
  */
1033
1210
  async signInWithPassword(
1034
1211
  credentials: SignInWithPasswordCredentials
@@ -1351,9 +1528,14 @@ export default class GoTrueClient {
1351
1528
  async exchangeCodeForSession(authCode: string): Promise<AuthTokenResponse> {
1352
1529
  await this.initializePromise
1353
1530
 
1354
- return this._acquireLock(this.lockAcquireTimeout, async () => {
1355
- return this._exchangeCodeForSession(authCode)
1356
- })
1531
+ if (this.lock != null) {
1532
+ // TODO(v3): remove legacy lock path
1533
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1534
+ return this._exchangeCodeForSession(authCode)
1535
+ })
1536
+ }
1537
+
1538
+ return this._exchangeCodeForSession(authCode)
1357
1539
  }
1358
1540
 
1359
1541
  /**
@@ -1827,7 +2009,10 @@ export default class GoTrueClient {
1827
2009
  }
1828
2010
  if (data.session) {
1829
2011
  await this._saveSession(data.session)
1830
- await this._notifyAllSubscribers('SIGNED_IN', data.session)
2012
+ await this._notifyAllSubscribers(
2013
+ redirectType === 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN',
2014
+ data.session
2015
+ )
1831
2016
  }
1832
2017
  return this._returnResult({ data: { ...data, redirectType: redirectType ?? null }, error })
1833
2018
  } catch (error) {
@@ -2248,7 +2433,7 @@ export default class GoTrueClient {
2248
2433
  }
2249
2434
 
2250
2435
  const session: Session | null = data.session
2251
- const user: User = data.user
2436
+ const user: User | null = data.user
2252
2437
 
2253
2438
  if (session?.access_token) {
2254
2439
  await this._saveSession(session as Session)
@@ -2387,9 +2572,14 @@ export default class GoTrueClient {
2387
2572
  async reauthenticate(): Promise<AuthResponse> {
2388
2573
  await this.initializePromise
2389
2574
 
2390
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2391
- return await this._reauthenticate()
2392
- })
2575
+ if (this.lock != null) {
2576
+ // TODO(v3): remove legacy lock path
2577
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2578
+ return await this._reauthenticate()
2579
+ })
2580
+ }
2581
+
2582
+ return await this._reauthenticate()
2393
2583
  }
2394
2584
 
2395
2585
  private async _reauthenticate(): Promise<AuthResponse> {
@@ -2480,15 +2670,28 @@ export default class GoTrueClient {
2480
2670
  const endpoint = `${this.url}/resend`
2481
2671
  if ('email' in credentials) {
2482
2672
  const { email, type, options } = credentials
2673
+ let codeChallenge: string | null = null
2674
+ let codeChallengeMethod: string | null = null
2675
+ if (this.flowType === 'pkce') {
2676
+ ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
2677
+ this.storage,
2678
+ this.storageKey
2679
+ )
2680
+ }
2483
2681
  const { error } = await _request(this.fetch, 'POST', endpoint, {
2484
2682
  headers: this.headers,
2485
2683
  body: {
2486
2684
  email,
2487
2685
  type,
2488
2686
  gotrue_meta_security: { captcha_token: options?.captchaToken },
2687
+ code_challenge: codeChallenge,
2688
+ code_challenge_method: codeChallengeMethod,
2489
2689
  },
2490
2690
  redirectTo: options?.emailRedirectTo,
2491
2691
  })
2692
+ if (error) {
2693
+ await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
2694
+ }
2492
2695
  return this._returnResult({ data: { user: null, session: null }, error })
2493
2696
  } else if ('phone' in credentials) {
2494
2697
  const { phone, type, options } = credentials
@@ -2509,6 +2712,7 @@ export default class GoTrueClient {
2509
2712
  'You must provide either an email or phone number and a type'
2510
2713
  )
2511
2714
  } catch (error) {
2715
+ await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
2512
2716
  if (isAuthError(error)) {
2513
2717
  return this._returnResult({ data: { user: null, session: null }, error })
2514
2718
  }
@@ -2525,7 +2729,7 @@ export default class GoTrueClient {
2525
2729
  * to the client. If that storage is based on request cookies for example,
2526
2730
  * the values in it may not be authentic and therefore it's strongly advised
2527
2731
  * against using this method and its results in such circumstances. A warning
2528
- * will be emitted if this is detected. Use {@link #getUser()} instead.
2732
+ * will be emitted if this is detected. Use {@link GoTrueClient.getUser} instead.
2529
2733
  *
2530
2734
  * @category Auth
2531
2735
  *
@@ -2536,7 +2740,7 @@ export default class GoTrueClient {
2536
2740
  * - If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
2537
2741
  * - 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.
2538
2742
  * - **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.
2539
- * - 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.
2743
+ * - 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.
2540
2744
  *
2541
2745
  * @example Get the session data
2542
2746
  * ```js
@@ -2604,17 +2808,26 @@ export default class GoTrueClient {
2604
2808
  async getSession() {
2605
2809
  await this.initializePromise
2606
2810
 
2607
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2608
- return this._useSession(async (result) => {
2609
- return result
2811
+ if (this.lock != null) {
2812
+ // TODO(v3): remove legacy lock path
2813
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2814
+ return this._useSession(async (result) => {
2815
+ return result
2816
+ })
2610
2817
  })
2611
- })
2818
+ }
2612
2819
 
2613
- return result
2820
+ return await this._useSession(async (result) => {
2821
+ return result
2822
+ })
2614
2823
  }
2615
2824
 
2616
2825
  /**
2617
2826
  * Acquires a global lock based on the storage key.
2827
+ *
2828
+ * TODO(v3): remove along with the legacy lock path. Only called when
2829
+ * `this.lock` is non-null (custom lock supplied via constructor). The
2830
+ * default lockless path bypasses this entirely.
2618
2831
  */
2619
2832
  private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
2620
2833
  this._debug('#_acquireLock', 'begin', acquireTimeout)
@@ -2634,7 +2847,7 @@ export default class GoTrueClient {
2634
2847
  (async () => {
2635
2848
  try {
2636
2849
  await result
2637
- } catch (e: any) {
2850
+ } catch (_e) {
2638
2851
  // we just care if it finished
2639
2852
  }
2640
2853
  })()
@@ -2643,7 +2856,7 @@ export default class GoTrueClient {
2643
2856
  return result
2644
2857
  }
2645
2858
 
2646
- return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
2859
+ return await this.lock!(`lock:${this.storageKey}`, acquireTimeout, async () => {
2647
2860
  this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
2648
2861
 
2649
2862
  try {
@@ -2685,10 +2898,9 @@ export default class GoTrueClient {
2685
2898
  }
2686
2899
 
2687
2900
  /**
2688
- * Use instead of {@link #getSession} inside the library. It is
2689
- * semantically usually what you want, as getting a session involves some
2690
- * processing afterwards that requires only one client operating on the
2691
- * session at once across multiple tabs or processes.
2901
+ * Use instead of {@link GoTrueClient.getSession} inside the library. Loads the session
2902
+ * via `__loadSession` (which may trigger a refresh if the access token is
2903
+ * within the expiry margin) and runs `fn` with the result.
2692
2904
  */
2693
2905
  private async _useSession<R>(
2694
2906
  fn: (
@@ -2716,7 +2928,10 @@ export default class GoTrueClient {
2716
2928
  this._debug('#_useSession', 'begin')
2717
2929
 
2718
2930
  try {
2719
- // the use of __loadSession here is the only correct use of the function!
2931
+ // Concurrent callers may both reach __loadSession; storage reads are
2932
+ // idempotent, and the only write path inside it (refresh) is
2933
+ // single-flighted downstream by `refreshingDeferred` in
2934
+ // `_callRefreshToken`. No serialization is needed at this layer.
2720
2935
  const result = await this.__loadSession()
2721
2936
 
2722
2937
  return await fn(result)
@@ -2728,7 +2943,7 @@ export default class GoTrueClient {
2728
2943
  /**
2729
2944
  * NEVER USE DIRECTLY!
2730
2945
  *
2731
- * Always use {@link #_useSession}.
2946
+ * Always use `_useSession`.
2732
2947
  */
2733
2948
  private async __loadSession(): Promise<
2734
2949
  | {
@@ -2752,7 +2967,8 @@ export default class GoTrueClient {
2752
2967
  > {
2753
2968
  this._debug('#__loadSession()', 'begin')
2754
2969
 
2755
- if (!this.lockAcquired) {
2970
+ if (this.lock != null && !this.lockAcquired) {
2971
+ // TODO(v3): remove. Only meaningful on the legacy lock path.
2756
2972
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack)
2757
2973
  }
2758
2974
 
@@ -2827,6 +3043,27 @@ export default class GoTrueClient {
2827
3043
 
2828
3044
  const { data: session, error } = await this._callRefreshToken(currentSession.refresh_token)
2829
3045
  if (error) {
3046
+ // Proactive-preserve mirror: `_callRefreshToken` keeps the session
3047
+ // in storage when refresh fails non-retryably but the access token
3048
+ // is still inside its real expiry window. Hand the caller the
3049
+ // still-valid session instead of translating the refresh error
3050
+ // into `session: null`. If the access token has actually expired,
3051
+ // the session is genuinely dead and the error stands. Explicit
3052
+ // refresh entry points (`refreshSession`, `setSession`)
3053
+ // intentionally bypass this fallback — they want to know the
3054
+ // refresh failed.
3055
+ const accessTokenStillValid = !!(
3056
+ currentSession.expires_at && currentSession.expires_at * 1000 > Date.now()
3057
+ )
3058
+ if (accessTokenStillValid) {
3059
+ // Race guard: a concurrent `signOut` may have cleared storage
3060
+ // during the refresh attempt. Don't hand back a session that no
3061
+ // longer exists on disk.
3062
+ const stillStored = (await getItemAsync(this.storage, this.storageKey)) as Session | null
3063
+ if (stillStored && stillStored.refresh_token === currentSession.refresh_token) {
3064
+ return this._returnResult({ data: { session: currentSession }, error: null })
3065
+ }
3066
+ }
2830
3067
  return this._returnResult({ data: { session: null }, error })
2831
3068
  }
2832
3069
 
@@ -2919,9 +3156,15 @@ export default class GoTrueClient {
2919
3156
 
2920
3157
  await this.initializePromise
2921
3158
 
2922
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2923
- return await this._getUser()
2924
- })
3159
+ let result: UserResponse
3160
+ if (this.lock != null) {
3161
+ // TODO(v3): remove legacy lock path
3162
+ result = await this._acquireLock(this.lockAcquireTimeout, async () => {
3163
+ return await this._getUser()
3164
+ })
3165
+ } else {
3166
+ result = await this._getUser()
3167
+ }
2925
3168
 
2926
3169
  if (result.data.user) {
2927
3170
  this.suppressGetSessionWarning = true
@@ -3096,9 +3339,14 @@ export default class GoTrueClient {
3096
3339
  ): Promise<UserResponse> {
3097
3340
  await this.initializePromise
3098
3341
 
3099
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3100
- return await this._updateUser(attributes, options)
3101
- })
3342
+ if (this.lock != null) {
3343
+ // TODO(v3): remove legacy lock path
3344
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3345
+ return await this._updateUser(attributes, options)
3346
+ })
3347
+ }
3348
+
3349
+ return await this._updateUser(attributes, options)
3102
3350
  }
3103
3351
 
3104
3352
  protected async _updateUser(
@@ -3285,9 +3533,14 @@ export default class GoTrueClient {
3285
3533
  }): Promise<AuthResponse> {
3286
3534
  await this.initializePromise
3287
3535
 
3288
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3289
- return await this._setSession(currentSession)
3290
- })
3536
+ if (this.lock != null) {
3537
+ // TODO(v3): remove legacy lock path
3538
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3539
+ return await this._setSession(currentSession)
3540
+ })
3541
+ }
3542
+
3543
+ return await this._setSession(currentSession)
3291
3544
  }
3292
3545
 
3293
3546
  protected async _setSession(currentSession: {
@@ -3476,9 +3729,14 @@ export default class GoTrueClient {
3476
3729
  async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> {
3477
3730
  await this.initializePromise
3478
3731
 
3479
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3480
- return await this._refreshSession(currentSession)
3481
- })
3732
+ if (this.lock != null) {
3733
+ // TODO(v3): remove legacy lock path
3734
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3735
+ return await this._refreshSession(currentSession)
3736
+ })
3737
+ }
3738
+
3739
+ return await this._refreshSession(currentSession)
3482
3740
  }
3483
3741
 
3484
3742
  protected async _refreshSession(currentSession?: {
@@ -3576,7 +3834,10 @@ export default class GoTrueClient {
3576
3834
 
3577
3835
  window.history.replaceState(window.history.state, '', url.toString())
3578
3836
 
3579
- return { data: { session: data.session, redirectType: null }, error: null }
3837
+ return {
3838
+ data: { session: data.session, redirectType: data.redirectType ?? null },
3839
+ error: null,
3840
+ }
3580
3841
  }
3581
3842
 
3582
3843
  const {
@@ -3664,7 +3925,9 @@ export default class GoTrueClient {
3664
3925
  if (typeof this.detectSessionInUrl === 'function') {
3665
3926
  return this.detectSessionInUrl(new URL(window.location.href), params)
3666
3927
  }
3667
- return Boolean(params.access_token || params.error_description)
3928
+ return Boolean(
3929
+ params.access_token || params.error || params.error_description || params.error_code
3930
+ )
3668
3931
  }
3669
3932
 
3670
3933
  /**
@@ -3687,24 +3950,35 @@ export default class GoTrueClient {
3687
3950
  *
3688
3951
  * If using `others` scope, no `SIGNED_OUT` event is fired!
3689
3952
  *
3953
+ * **Warning:** the default `scope` is `'global'`. This signs the user out of
3954
+ * **every device they are currently signed in on**, not just the current
3955
+ * tab/session. If you only want to sign the user out of the current session
3956
+ * (the behavior most other auth libraries default to), pass
3957
+ * `{ scope: 'local' }` explicitly.
3958
+ *
3690
3959
  * @category Auth
3691
3960
  *
3692
3961
  * @remarks
3693
3962
  * - In order to use the `signOut()` method, the user needs to be signed in first.
3694
- * - By default, `signOut()` uses the global scope, which signs out all other sessions that the user is logged into as well. Customize this behavior by passing a scope parameter.
3963
+ * - By default, `signOut()` uses the **global** scope, which signs out the user
3964
+ * on every device they are signed in on (not just the current one). Pass
3965
+ * `{ scope: 'local' }` to only sign out the current session. This is
3966
+ * usually what apps want on a "Sign out" button, especially when users
3967
+ * sign in from multiple devices and do not expect signing out of one to
3968
+ * terminate the others.
3695
3969
  * - Since Supabase Auth uses JWTs for authentication, the access token JWT will be valid until it's expired. When the user signs out, Supabase revokes the refresh token and deletes the JWT from the client-side. This does not revoke the JWT and it will still be valid until it expires.
3696
3970
  *
3697
- * @example Sign out (all sessions)
3971
+ * @example Sign out of every device (global – default)
3698
3972
  * ```js
3699
3973
  * const { error } = await supabase.auth.signOut()
3700
3974
  * ```
3701
3975
  *
3702
- * @example Sign out (current session)
3976
+ * @example Sign out only the current session (recommended for most apps)
3703
3977
  * ```js
3704
3978
  * const { error } = await supabase.auth.signOut({ scope: 'local' })
3705
3979
  * ```
3706
3980
  *
3707
- * @example Sign out (other sessions)
3981
+ * @example Sign out of all other sessions, keep the current one
3708
3982
  * ```js
3709
3983
  * const { error } = await supabase.auth.signOut({ scope: 'others' })
3710
3984
  * ```
@@ -3712,15 +3986,24 @@ export default class GoTrueClient {
3712
3986
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
3713
3987
  await this.initializePromise
3714
3988
 
3715
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3716
- return await this._signOut(options)
3717
- })
3989
+ if (this.lock != null) {
3990
+ // TODO(v3): remove legacy lock path
3991
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3992
+ return await this._signOut(options)
3993
+ })
3994
+ }
3995
+
3996
+ return await this._signOut(options)
3718
3997
  }
3719
3998
 
3720
3999
  protected async _signOut(
3721
4000
  { scope }: SignOut = { scope: 'global' }
3722
4001
  ): Promise<{ error: AuthError | null }> {
3723
4002
  return await this._useSession(async (result) => {
4003
+ const removeCurrentSession = async () => {
4004
+ await this._removeSession()
4005
+ await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
4006
+ }
3724
4007
  const { data, error: sessionError } = result
3725
4008
  if (sessionError && !isAuthSessionMissingError(sessionError)) {
3726
4009
  return this._returnResult({ error: sessionError })
@@ -3738,13 +4021,15 @@ export default class GoTrueClient {
3738
4021
  isAuthSessionMissingError(error)
3739
4022
  )
3740
4023
  ) {
4024
+ if (scope !== 'others') {
4025
+ await removeCurrentSession()
4026
+ }
3741
4027
  return this._returnResult({ error })
3742
4028
  }
3743
4029
  }
3744
4030
  }
3745
4031
  if (scope !== 'others') {
3746
- await this._removeSession()
3747
- await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
4032
+ await removeCurrentSession()
3748
4033
  }
3749
4034
  return this._returnResult({ error: null })
3750
4035
  })
@@ -3761,16 +4046,19 @@ export default class GoTrueClient {
3761
4046
  }
3762
4047
 
3763
4048
  /**
3764
- * Avoid using an async function inside `onAuthStateChange` as you might end
3765
- * up with a deadlock. The callback function runs inside an exclusive lock,
3766
- * so calling other Supabase Client APIs that also try to acquire the
3767
- * exclusive lock, might cause a deadlock. This behavior is observable across
3768
- * tabs. In the next major library version, this behavior will not be supported.
3769
- *
3770
- * Receive a notification every time an auth event happens.
4049
+ * Receive a notification every time an auth event happens. Common reentry
4050
+ * patterns (`getUser`, `setSession`, reading the session from inside a
4051
+ * handler) complete normally. One hazard remains: calling `refreshSession`
4052
+ * (or anything that routes through `_callRefreshToken`) from inside a
4053
+ * `TOKEN_REFRESHED` handler. `refreshingDeferred` resolves only after
4054
+ * `_notifyAllSubscribers` returns, so the inner refresh dedupes onto the
4055
+ * outer's unresolved promise and the two wait on each other.
3771
4056
  *
3772
4057
  * @param callback A callback function to be invoked when an auth event happens.
3773
- * @deprecated Due to the possibility of deadlocks with async functions as callbacks, use the version without an async function.
4058
+ *
4059
+ * @deprecated Async callbacks can deadlock when they trigger a nested
4060
+ * refresh from a `TOKEN_REFRESHED` event. Prefer the sync overload, or move
4061
+ * refresh-triggering work outside the callback.
3774
4062
  */
3775
4063
  onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => Promise<void>): {
3776
4064
  data: { subscription: Subscription }
@@ -3783,18 +4071,8 @@ export default class GoTrueClient {
3783
4071
  * - Subscribes to important events occurring on the user's session.
3784
4072
  * - Use on the frontend/client. It is less useful on the server.
3785
4073
  * - 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.
3786
- * - **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.
3787
- * - Avoid using `async` functions as callbacks.
3788
- * - Limit the number of `await` calls in `async` callbacks.
3789
- * - 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:
3790
- * ```js
3791
- * supabase.auth.onAuthStateChange((event, session) => {
3792
- * setTimeout(async () => {
3793
- * // await on other Supabase function here
3794
- * // this runs right after the callback has finished
3795
- * }, 0)
3796
- * })
3797
- * ```
4074
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
4075
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3798
4076
  * - Emitted events:
3799
4077
  * - `INITIAL_SESSION`
3800
4078
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -3985,9 +4263,14 @@ export default class GoTrueClient {
3985
4263
  ;(async () => {
3986
4264
  await this.initializePromise
3987
4265
 
3988
- await this._acquireLock(this.lockAcquireTimeout, async () => {
3989
- this._emitInitialSession(id)
3990
- })
4266
+ if (this.lock != null) {
4267
+ // TODO(v3): remove legacy lock path
4268
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4269
+ this._emitInitialSession(id)
4270
+ })
4271
+ } else {
4272
+ await this._emitInitialSession(id)
4273
+ }
3991
4274
  })()
3992
4275
 
3993
4276
  return { data: { subscription } }
@@ -4007,7 +4290,10 @@ export default class GoTrueClient {
4007
4290
  } catch (err) {
4008
4291
  await this.stateChangeEmitters.get(id)?.callback('INITIAL_SESSION', null)
4009
4292
  this._debug('INITIAL_SESSION', 'callback id', id, 'error', err)
4010
- if (isAuthSessionMissingError(err)) {
4293
+ if (isAuthSessionMissingError(err) || isAuthRetryableFetchError(err)) {
4294
+ // A missing session or a transient/aborted network failure (e.g. a
4295
+ // superseded page navigation cancelling the in-flight request) is
4296
+ // not an application error — warn rather than surface it raw.
4011
4297
  console.warn(err)
4012
4298
  } else {
4013
4299
  console.error(err)
@@ -4382,7 +4668,10 @@ export default class GoTrueClient {
4382
4668
  * @param refreshToken A valid refresh token that was returned on login.
4383
4669
  */
4384
4670
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
4385
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
4671
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4672
+ // fragment of the token in the debug tag, even when `debug: true` is
4673
+ // enabled (logs may be forwarded to third-party services).
4674
+ const debugName = `#_refreshAccessToken()`
4386
4675
  this._debug(debugName, 'begin')
4387
4676
 
4388
4677
  try {
@@ -4535,15 +4824,16 @@ export default class GoTrueClient {
4535
4824
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4536
4825
 
4537
4826
  if (error) {
4538
- console.error(error)
4539
-
4540
- if (!isAuthRetryableFetchError(error)) {
4541
- this._debug(
4542
- debugName,
4543
- 'refresh failed with a non-retryable error, removing the session',
4544
- error
4545
- )
4546
- await this._removeSession()
4827
+ // `_callRefreshToken` is the single source of truth for refresh
4828
+ // outcomes: it removes the session itself when the access token
4829
+ // is actually expired, and preserves it when the token is still
4830
+ // valid (proactive-preserve). Don't second-guess that here — a
4831
+ // local `_removeSession` would emit a duplicate `SIGNED_OUT` on
4832
+ // genuine failures and undo the proactive-preserve at init time.
4833
+ if (isAuthRefreshDiscardedError(error)) {
4834
+ this._debug(debugName, 'refresh discarded by commit guard', error)
4835
+ } else {
4836
+ this._debug(debugName, 'refresh failed', error)
4547
4837
  }
4548
4838
  }
4549
4839
  }
@@ -4579,7 +4869,15 @@ export default class GoTrueClient {
4579
4869
  } catch (err) {
4580
4870
  this._debug(debugName, 'error', err)
4581
4871
 
4582
- console.error(err)
4872
+ if (isAuthRetryableFetchError(err)) {
4873
+ // Transient/aborted network failure during session recovery (e.g. a
4874
+ // superseded page navigation cancelling the in-flight request). Warn
4875
+ // rather than surface it raw; the session is untouched and recovery
4876
+ // will run again on the next load.
4877
+ console.warn(err)
4878
+ } else {
4879
+ console.error(err)
4880
+ }
4583
4881
  return
4584
4882
  } finally {
4585
4883
  this._debug(debugName, 'end')
@@ -4596,22 +4894,113 @@ export default class GoTrueClient {
4596
4894
  return this.refreshingDeferred.promise
4597
4895
  }
4598
4896
 
4599
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
4897
+ // Serial failure cooldown: callers passing the *same* refresh token
4898
+ // after a recent failure receive the cached result instead of firing
4899
+ // another `/token` request. This caps the proactive-refresh storm
4900
+ // where every `getSession()` call inside the 90s EXPIRY_MARGIN_MS
4901
+ // window kept re-firing against the same broken refresh token during
4902
+ // outages. Concurrent callers already share `refreshingDeferred`; this
4903
+ // cache covers serial callers spaced across cooldown windows.
4904
+ //
4905
+ // Token-keyed so callers with a fresh refresh token (rotation pickup
4906
+ // from another tab, explicit `setSession`/`refreshSession({ refresh_token })`,
4907
+ // multi-account switch) bypass the cache and attempt a real refresh.
4908
+ if (
4909
+ this.lastRefreshFailure &&
4910
+ this.lastRefreshFailure.refreshToken === refreshToken &&
4911
+ Date.now() < this.lastRefreshFailure.expiresAt
4912
+ ) {
4913
+ this._debug('#_callRefreshToken()', 'returning cached failure (cooldown active)')
4914
+ return this.lastRefreshFailure.result
4915
+ }
4916
+
4917
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
4918
+ // fragment of the token in the debug tag, even when `debug: true` is
4919
+ // enabled (logs may be forwarded to third-party services).
4920
+ const debugName = `#_callRefreshToken()`
4600
4921
 
4601
4922
  this._debug(debugName, 'begin')
4602
4923
 
4603
4924
  try {
4604
4925
  this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
4605
4926
 
4927
+ // Snapshot storage before the fetch. The commit guard discards the
4928
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
4929
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
4930
+ // another tab's refresh rewrote the slot. Callers passing
4931
+ // externally-sourced tokens (SSR cookie handoff, multi-account
4932
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
4933
+ // start from a null snapshot OR from a non-null snapshot whose
4934
+ // refresh_token differs from the one they're hydrating; in both
4935
+ // cases the guard fires only when storage was *modified between
4936
+ // snapshots*, not when the input token disagrees with what's stored.
4937
+ const storedAtStart = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4938
+
4606
4939
  const { data, error } = await this._refreshAccessToken(refreshToken)
4607
4940
  if (error) throw error
4608
4941
  if (!data.session) throw new AuthSessionMissingError()
4609
4942
 
4943
+ const storedAfter = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4944
+ const storageChangedUnderUs =
4945
+ storedAtStart !== null &&
4946
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token)
4947
+
4948
+ if (storageChangedUnderUs) {
4949
+ this._debug(
4950
+ debugName,
4951
+ 'commit guard: storage changed since refresh started, discarding rotated tokens',
4952
+ {
4953
+ // Presence indicators only — never log refresh token fragments,
4954
+ // even partial. Logs may be forwarded to third-party services.
4955
+ startedWith: 'present',
4956
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4957
+ }
4958
+ )
4959
+ const discarded: CallRefreshTokenResult = {
4960
+ data: null,
4961
+ error: new AuthRefreshDiscardedError(),
4962
+ }
4963
+ this.refreshingDeferred.resolve(discarded)
4964
+ return discarded
4965
+ }
4966
+
4967
+ // Second leg of the commit guard: close the TOCTOU window between the
4968
+ // synchronous `storageChangedUnderUs` check and the actual storage
4969
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4970
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4971
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4972
+ // the save and re-check after; if it advanced, undo the write directly
4973
+ // (do NOT call `_removeSession` — that would emit a duplicate
4974
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4975
+ const epochBeforeSave = this._sessionRemovalEpoch
4976
+
4610
4977
  await this._saveSession(data.session)
4978
+
4979
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4980
+ this._debug(
4981
+ debugName,
4982
+ 'commit guard (post-save): _removeSession ran during _saveSession, undoing write'
4983
+ )
4984
+ await removeItemAsync(this.storage, this.storageKey)
4985
+ if (this.userStorage) {
4986
+ await removeItemAsync(this.userStorage, this.storageKey + '-user')
4987
+ }
4988
+ const discarded: CallRefreshTokenResult = {
4989
+ data: null,
4990
+ error: new AuthRefreshDiscardedError(),
4991
+ }
4992
+ this.refreshingDeferred.resolve(discarded)
4993
+ return discarded
4994
+ }
4995
+
4611
4996
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
4612
4997
 
4613
4998
  const result = { data: data.session, error: null }
4614
4999
 
5000
+ // Refresh succeeded — clear any cached failure so the next caller
5001
+ // (including the auto-refresh ticker) attempts a real refresh again.
5002
+ this.lastRefreshFailure = null
5003
+
4615
5004
  this.refreshingDeferred.resolve(result)
4616
5005
 
4617
5006
  return result
@@ -4622,7 +5011,40 @@ export default class GoTrueClient {
4622
5011
  const result = { data: null, error }
4623
5012
 
4624
5013
  if (!isAuthRetryableFetchError(error)) {
4625
- await this._removeSession()
5014
+ // Proactive vs reactive distinction: a refresh fires whenever
5015
+ // the access token is within EXPIRY_MARGIN_MS of expiry. If the
5016
+ // access token is *still valid* at this moment, the refresh was
5017
+ // proactive and the existing session is still usable until its
5018
+ // real expiry — destroying it now would log out a user whose
5019
+ // access token works. If the access token has actually expired,
5020
+ // the refresh token is the only credential left and it just got
5021
+ // rejected — the session is genuinely dead. `__loadSession`
5022
+ // mirrors this distinction on the read path so callers see the
5023
+ // preserved session instead of `session: null`.
5024
+ const storedNow = (await getItemAsync(this.storage, this.storageKey)) as Session | null
5025
+ const accessTokenStillValid = !!(
5026
+ storedNow?.expires_at && storedNow.expires_at * 1000 > Date.now()
5027
+ )
5028
+
5029
+ if (accessTokenStillValid) {
5030
+ this._debug(
5031
+ debugName,
5032
+ 'proactive refresh failed, access token still valid — preserving session'
5033
+ )
5034
+ } else {
5035
+ await this._removeSession()
5036
+ }
5037
+ }
5038
+
5039
+ // Cache the failure so serial callers (and the next auto-refresh
5040
+ // tick) passing the same refresh token within the cooldown window
5041
+ // receive it synchronously instead of firing another `/token`
5042
+ // call. Set after the optional `_removeSession` above (which
5043
+ // clears the cache as part of teardown) so the cache survives.
5044
+ this.lastRefreshFailure = {
5045
+ refreshToken,
5046
+ result,
5047
+ expiresAt: Date.now() + REFRESH_FAILURE_COOLDOWN_MS,
4626
5048
  }
4627
5049
 
4628
5050
  this.refreshingDeferred?.resolve(result)
@@ -4643,6 +5065,20 @@ export default class GoTrueClient {
4643
5065
  session: Session | null,
4644
5066
  broadcast = true
4645
5067
  ) {
5068
+ if (this._pendingInitNotifications !== null && broadcast) {
5069
+ // We're inside initialize() before initializePromise has resolved, and
5070
+ // this notification originates from the init chain (_recoverAndRefresh),
5071
+ // which always fires with broadcast = true. Enqueue instead of firing so
5072
+ // that callbacks can safely call getSession() / getUser() without
5073
+ // deadlocking on initializePromise.
5074
+ //
5075
+ // Notifications with broadcast = false come from the incoming
5076
+ // BroadcastChannel handler (a cross-tab event), not the init chain, so
5077
+ // they are fired immediately to preserve multi-tab ordering.
5078
+ this._pendingInitNotifications.push({ event, session, broadcast })
5079
+ return
5080
+ }
5081
+
4646
5082
  const debugName = `#_notifyAllSubscribers(${event})`
4647
5083
  this._debug(debugName, 'begin', session, `broadcast = ${broadcast}`)
4648
5084
 
@@ -4651,11 +5087,11 @@ export default class GoTrueClient {
4651
5087
  this.broadcastChannel.postMessage({ event, session })
4652
5088
  }
4653
5089
 
4654
- const errors: any[] = []
5090
+ const errors: unknown[] = []
4655
5091
  const promises = Array.from(this.stateChangeEmitters.values()).map(async (x) => {
4656
5092
  try {
4657
5093
  await x.callback(event, session)
4658
- } catch (e: any) {
5094
+ } catch (e) {
4659
5095
  errors.push(e)
4660
5096
  }
4661
5097
  })
@@ -4683,7 +5119,6 @@ export default class GoTrueClient {
4683
5119
  // _saveSession is always called whenever a new session has been acquired
4684
5120
  // so we can safely suppress the warning returned by future getSession calls
4685
5121
  this.suppressGetSessionWarning = true
4686
- await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
4687
5122
  // Create a shallow copy to work with, to avoid mutating the original session object if it's used elsewhere
4688
5123
  const sessionToProcess = { ...session }
4689
5124
 
@@ -4719,8 +5154,17 @@ export default class GoTrueClient {
4719
5154
  }
4720
5155
 
4721
5156
  private async _removeSession() {
5157
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
5158
+ // post-save check sees the increment whenever this method has started —
5159
+ // even if it hasn't finished. Pairs with the epoch check in
5160
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
5161
+ this._sessionRemovalEpoch += 1
4722
5162
  this._debug('#_removeSession()')
4723
5163
 
5164
+ // The session is gone — no point holding on to a cached refresh failure
5165
+ // for a token that no longer exists. Synchronous, before any `await`.
5166
+ this.lastRefreshFailure = null
5167
+
4724
5168
  this.suppressGetSessionWarning = false
4725
5169
 
4726
5170
  await removeItemAsync(this.storage, this.storageKey)
@@ -4737,8 +5181,8 @@ export default class GoTrueClient {
4737
5181
  /**
4738
5182
  * Removes any registered visibilitychange callback.
4739
5183
  *
4740
- * {@see #startAutoRefresh}
4741
- * {@see #stopAutoRefresh}
5184
+ * {@link GoTrueClient.startAutoRefresh}
5185
+ * {@link GoTrueClient.stopAutoRefresh}
4742
5186
  */
4743
5187
  private _removeVisibilityChangedCallback() {
4744
5188
  this._debug('#_removeVisibilityChangedCallback()')
@@ -4756,7 +5200,7 @@ export default class GoTrueClient {
4756
5200
  }
4757
5201
 
4758
5202
  /**
4759
- * This is the private implementation of {@link #startAutoRefresh}. Use this
5203
+ * This is the private implementation of {@link GoTrueClient.startAutoRefresh}. Use this
4760
5204
  * within the library.
4761
5205
  */
4762
5206
  private async _startAutoRefresh() {
@@ -4802,7 +5246,7 @@ export default class GoTrueClient {
4802
5246
  }
4803
5247
 
4804
5248
  /**
4805
- * This is the private implementation of {@link #stopAutoRefresh}. Use this
5249
+ * This is the private implementation of {@link GoTrueClient.stopAutoRefresh}. Use this
4806
5250
  * within the library.
4807
5251
  */
4808
5252
  private async _stopAutoRefresh() {
@@ -4843,7 +5287,7 @@ export default class GoTrueClient {
4843
5287
  * platform's foreground indication mechanism and call these methods
4844
5288
  * appropriately to conserve resources.
4845
5289
  *
4846
- * {@see #stopAutoRefresh}
5290
+ * {@link GoTrueClient.stopAutoRefresh}
4847
5291
  *
4848
5292
  * @category Auth
4849
5293
  *
@@ -4878,7 +5322,7 @@ export default class GoTrueClient {
4878
5322
  * If you call this method any managed visibility change callback will be
4879
5323
  * removed and you must manage visibility changes on your own.
4880
5324
  *
4881
- * See {@link #startAutoRefresh} for more details.
5325
+ * See {@link GoTrueClient.startAutoRefresh} for more details.
4882
5326
  *
4883
5327
  * @category Auth
4884
5328
  *
@@ -4907,58 +5351,144 @@ export default class GoTrueClient {
4907
5351
  await this._stopAutoRefresh()
4908
5352
  }
4909
5353
 
5354
+ /**
5355
+ * Tears down the client's background work: stops the auto-refresh interval,
5356
+ * removes the `visibilitychange` listener, closes the cross-tab
5357
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
5358
+ *
5359
+ * Call this from cleanup hooks when the client is being replaced before
5360
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
5361
+ * cases. Any in-flight `fetch` calls continue to completion and may still
5362
+ * write to storage; dispose doesn't abort them or erase storage.
5363
+ *
5364
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
5365
+ * disposed instance can still persist a rotated session to storage after
5366
+ * `dispose()` returns. A subsequent `createClient` against the same
5367
+ * `storageKey` will pick up that session on its next read. If you need
5368
+ * strict isolation between client lifecycles, await any pending auth
5369
+ * operation before calling `dispose()` (or change the `storageKey` for
5370
+ * the replacement client).
5371
+ *
5372
+ * Safe to call repeatedly.
5373
+ *
5374
+ * @category Auth
5375
+ *
5376
+ * @example Cleanup on React unmount
5377
+ * ```ts
5378
+ * useEffect(() => {
5379
+ * const client = createClient(...)
5380
+ * return () => { client.auth.dispose() }
5381
+ * }, [])
5382
+ * ```
5383
+ */
5384
+ async dispose(): Promise<void> {
5385
+ this._removeVisibilityChangedCallback()
5386
+ await this._stopAutoRefresh()
5387
+ this.broadcastChannel?.close()
5388
+ this.broadcastChannel = null
5389
+ this.stateChangeEmitters.clear()
5390
+ }
5391
+
4910
5392
  /**
4911
5393
  * Runs the auto refresh token tick.
4912
5394
  */
4913
5395
  private async _autoRefreshTokenTick() {
4914
5396
  this._debug('#_autoRefreshTokenTick()', 'begin')
4915
5397
 
4916
- try {
4917
- await this._acquireLock(0, async () => {
4918
- try {
4919
- const now = Date.now()
4920
-
5398
+ if (this.lock != null) {
5399
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
5400
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
5401
+ // that's the fail-fast skip path that lets the tick bail out instead
5402
+ // of queuing behind a long-running operation.
5403
+ try {
5404
+ await this._acquireLock(0, async () => {
4921
5405
  try {
4922
- return await this._useSession(async (result) => {
4923
- const {
4924
- data: { session },
4925
- } = result
4926
-
4927
- if (!session || !session.refresh_token || !session.expires_at) {
4928
- this._debug('#_autoRefreshTokenTick()', 'no session')
4929
- return
4930
- }
5406
+ const now = Date.now()
5407
+ try {
5408
+ return await this._useSession(async (result) => {
5409
+ const {
5410
+ data: { session },
5411
+ } = result
5412
+
5413
+ if (!session || !session.refresh_token || !session.expires_at) {
5414
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5415
+ return
5416
+ }
4931
5417
 
4932
- // session will expire in this many ticks (or has already expired if <= 0)
4933
- const expiresInTicks = Math.floor(
4934
- (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
4935
- )
5418
+ const expiresInTicks = Math.floor(
5419
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5420
+ )
4936
5421
 
4937
- this._debug(
4938
- '#_autoRefreshTokenTick()',
4939
- `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
4940
- )
5422
+ this._debug(
5423
+ '#_autoRefreshTokenTick()',
5424
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5425
+ )
4941
5426
 
4942
- if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
4943
- await this._callRefreshToken(session.refresh_token)
4944
- }
4945
- })
4946
- } catch (e: any) {
4947
- console.error(
4948
- 'Auto refresh tick failed with error. This is likely a transient error.',
4949
- e
4950
- )
5427
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5428
+ await this._callRefreshToken(session.refresh_token)
5429
+ }
5430
+ })
5431
+ } catch (e) {
5432
+ console.error(
5433
+ 'Auto refresh tick failed with error. This is likely a transient error.',
5434
+ e
5435
+ )
5436
+ }
5437
+ } finally {
5438
+ this._debug('#_autoRefreshTokenTick()', 'end')
4951
5439
  }
4952
- } finally {
4953
- this._debug('#_autoRefreshTokenTick()', 'end')
5440
+ })
5441
+ } catch (e) {
5442
+ if (e instanceof LockAcquireTimeoutError) {
5443
+ this._debug('auto refresh token tick lock not available')
5444
+ } else {
5445
+ throw e
4954
5446
  }
4955
- })
4956
- } catch (e: any) {
4957
- if (e.isAcquireTimeout || e instanceof LockAcquireTimeoutError) {
4958
- this._debug('auto refresh token tick lock not available')
4959
- } else {
4960
- throw e
4961
5447
  }
5448
+ return
5449
+ }
5450
+
5451
+ // Lockless default: skip if a refresh is already in flight.
5452
+ // `_callRefreshToken` also dedupes via the same field; this is just a
5453
+ // fast-path skip to avoid an unnecessary storage read.
5454
+ if (this.refreshingDeferred !== null) {
5455
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping')
5456
+ return
5457
+ }
5458
+
5459
+ try {
5460
+ const now = Date.now()
5461
+
5462
+ try {
5463
+ await this._useSession(async (result) => {
5464
+ const {
5465
+ data: { session },
5466
+ } = result
5467
+
5468
+ if (!session || !session.refresh_token || !session.expires_at) {
5469
+ this._debug('#_autoRefreshTokenTick()', 'no session')
5470
+ return
5471
+ }
5472
+
5473
+ // session will expire in this many ticks (or has already expired if <= 0)
5474
+ const expiresInTicks = Math.floor(
5475
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
5476
+ )
5477
+
5478
+ this._debug(
5479
+ '#_autoRefreshTokenTick()',
5480
+ `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
5481
+ )
5482
+
5483
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
5484
+ await this._callRefreshToken(session.refresh_token)
5485
+ }
5486
+ })
5487
+ } catch (e) {
5488
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
5489
+ }
5490
+ } finally {
5491
+ this._debug('#_autoRefreshTokenTick()', 'end')
4962
5492
  }
4963
5493
  }
4964
5494
 
@@ -5015,24 +5545,29 @@ export default class GoTrueClient {
5015
5545
  if (!calledFromInitialize) {
5016
5546
  // called when the visibility has changed, i.e. the browser
5017
5547
  // transitioned from hidden -> visible so we need to see if the session
5018
- // should be recovered immediately... but to do that we need to acquire
5019
- // the lock first asynchronously
5548
+ // should be recovered
5020
5549
  await this.initializePromise
5021
5550
 
5022
- await this._acquireLock(this.lockAcquireTimeout, async () => {
5551
+ if (this.lock != null) {
5552
+ // TODO(v3): remove legacy lock path
5553
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
5554
+ if (document.visibilityState !== 'visible') {
5555
+ this._debug(
5556
+ methodName,
5557
+ 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5558
+ )
5559
+ return
5560
+ }
5561
+ await this._recoverAndRefresh()
5562
+ })
5563
+ } else {
5023
5564
  if (document.visibilityState !== 'visible') {
5024
- this._debug(
5025
- methodName,
5026
- 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
5027
- )
5028
-
5029
- // visibility has changed while waiting for the lock, abort
5565
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery')
5030
5566
  return
5031
5567
  }
5032
-
5033
5568
  // recover the session
5034
5569
  await this._recoverAndRefresh()
5035
- })
5570
+ }
5036
5571
  }
5037
5572
  } else if (document.visibilityState === 'hidden') {
5038
5573
  if (this.autoRefreshToken) {
@@ -5109,7 +5644,7 @@ export default class GoTrueClient {
5109
5644
  }
5110
5645
 
5111
5646
  /**
5112
- * {@see GoTrueMFAApi#enroll}
5647
+ * {@link GoTrueMFAApi#enroll}
5113
5648
  */
5114
5649
  private async _enroll(params: MFAEnrollTOTPParams): Promise<AuthMFAEnrollTOTPResponse>
5115
5650
  private async _enroll(params: MFAEnrollPhoneParams): Promise<AuthMFAEnrollPhoneResponse>
@@ -5156,7 +5691,7 @@ export default class GoTrueClient {
5156
5691
  }
5157
5692
 
5158
5693
  /**
5159
- * {@see GoTrueMFAApi#verify}
5694
+ * {@link GoTrueMFAApi#verify}
5160
5695
  */
5161
5696
  private async _verify(params: MFAVerifyTOTPParams): Promise<AuthMFAVerifyResponse>
5162
5697
  private async _verify(params: MFAVerifyPhoneParams): Promise<AuthMFAVerifyResponse>
@@ -5164,7 +5699,7 @@ export default class GoTrueClient {
5164
5699
  params: MFAVerifyWebauthnParams<T>
5165
5700
  ): Promise<AuthMFAVerifyResponse>
5166
5701
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
5167
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5702
+ const run = async (): Promise<AuthMFAVerifyResponse> => {
5168
5703
  try {
5169
5704
  return await this._useSession(async (result) => {
5170
5705
  const { data: sessionData, error: sessionError } = result
@@ -5235,11 +5770,17 @@ export default class GoTrueClient {
5235
5770
  }
5236
5771
  throw error
5237
5772
  }
5238
- })
5773
+ }
5774
+
5775
+ if (this.lock != null) {
5776
+ // TODO(v3): remove legacy lock path
5777
+ return this._acquireLock(this.lockAcquireTimeout, run)
5778
+ }
5779
+ return run()
5239
5780
  }
5240
5781
 
5241
5782
  /**
5242
- * {@see GoTrueMFAApi#challenge}
5783
+ * {@link GoTrueMFAApi#challenge}
5243
5784
  */
5244
5785
  private async _challenge(
5245
5786
  params: MFAChallengeTOTPParams
@@ -5251,7 +5792,7 @@ export default class GoTrueClient {
5251
5792
  params: MFAChallengeWebauthnParams
5252
5793
  ): Promise<Prettify<AuthMFAChallengeWebauthnResponse>>
5253
5794
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
5254
- return this._acquireLock(this.lockAcquireTimeout, async () => {
5795
+ const run = async (): Promise<AuthMFAChallengeResponse> => {
5255
5796
  try {
5256
5797
  return await this._useSession(async (result) => {
5257
5798
  const { data: sessionData, error: sessionError } = result
@@ -5324,18 +5865,21 @@ export default class GoTrueClient {
5324
5865
  }
5325
5866
  throw error
5326
5867
  }
5327
- })
5868
+ }
5869
+
5870
+ if (this.lock != null) {
5871
+ // TODO(v3): remove legacy lock path
5872
+ return this._acquireLock(this.lockAcquireTimeout, run)
5873
+ }
5874
+ return run()
5328
5875
  }
5329
5876
 
5330
5877
  /**
5331
- * {@see GoTrueMFAApi#challengeAndVerify}
5878
+ * {@link GoTrueMFAApi#challengeAndVerify}
5332
5879
  */
5333
5880
  private async _challengeAndVerify(
5334
5881
  params: MFAChallengeAndVerifyParams
5335
5882
  ): Promise<AuthMFAVerifyResponse> {
5336
- // both _challenge and _verify independently acquire the lock, so no need
5337
- // to acquire it here
5338
-
5339
5883
  const { data: challengeData, error: challengeError } = await this._challenge({
5340
5884
  factorId: params.factorId,
5341
5885
  })
@@ -5351,10 +5895,9 @@ export default class GoTrueClient {
5351
5895
  }
5352
5896
 
5353
5897
  /**
5354
- * {@see GoTrueMFAApi#listFactors}
5898
+ * {@link GoTrueMFAApi#listFactors}
5355
5899
  */
5356
5900
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
5357
- // use #getUser instead of #_getUser as the former acquires a lock
5358
5901
  const {
5359
5902
  data: { user },
5360
5903
  error: userError,
@@ -5385,7 +5928,7 @@ export default class GoTrueClient {
5385
5928
  }
5386
5929
 
5387
5930
  /**
5388
- * {@see GoTrueMFAApi#getAuthenticatorAssuranceLevel}
5931
+ * {@link GoTrueMFAApi#getAuthenticatorAssuranceLevel}
5389
5932
  */
5390
5933
  private async _getAuthenticatorAssuranceLevel(
5391
5934
  jwt?: string
@@ -5733,15 +6276,15 @@ export default class GoTrueClient {
5733
6276
  * Extracts the JWT claims present in the access token by first verifying the
5734
6277
  * JWT against the server's JSON Web Key Set endpoint
5735
6278
  * `/.well-known/jwks.json` which is often cached, resulting in significantly
5736
- * faster responses. Prefer this method over {@link #getUser} which always
6279
+ * faster responses. Prefer this method over {@link GoTrueClient.getUser} which always
5737
6280
  * sends a request to the Auth server for each JWT.
5738
6281
  *
5739
6282
  * If the project is not using an asymmetric JWT signing key (like ECC or
5740
- * RSA) it always sends a request to the Auth server (similar to {@link
5741
- * #getUser}) to verify the JWT.
6283
+ * RSA) it always sends a request to the Auth server (similar to
6284
+ * {@link GoTrueClient.getUser}) to verify the JWT.
5742
6285
  *
5743
6286
  * @param jwt An optional specific JWT you wish to verify, not the one you
5744
- * can obtain from {@link #getSession}.
6287
+ * can obtain from {@link GoTrueClient.getSession}.
5745
6288
  * @param options Various additional options that allow you to customize the
5746
6289
  * behavior of this method.
5747
6290
  *
@@ -5834,8 +6377,13 @@ export default class GoTrueClient {
5834
6377
  } = decodeJWT(token)
5835
6378
 
5836
6379
  if (!options?.allowExpired) {
5837
- // Reject expired JWTs should only happen if jwt argument was passed
5838
- validateExp(payload.exp)
6380
+ // Reject expired JWTs should only happen if jwt argument was passed.
6381
+ // Rethrow as AuthInvalidJwtError so the outer catch converts it to { data, error }.
6382
+ try {
6383
+ validateExp(payload.exp)
6384
+ } catch (e) {
6385
+ throw new AuthInvalidJwtError(e instanceof Error ? e.message : 'JWT validation failed')
6386
+ }
5839
6387
  }
5840
6388
 
5841
6389
  const signingKey =
@@ -5898,4 +6446,392 @@ export default class GoTrueClient {
5898
6446
  throw error
5899
6447
  }
5900
6448
  }
6449
+
6450
+ // --- Passkey Methods ---
6451
+
6452
+ /**
6453
+ * Sign in with a passkey. Handles the full WebAuthn ceremony:
6454
+ * 1. Fetches authentication challenge from server
6455
+ * 2. Prompts user via navigator.credentials.get()
6456
+ * 3. Verifies credential with server and creates session
6457
+ *
6458
+ * Requires `auth.experimental.passkey: true`.
6459
+ *
6460
+ * @category Auth
6461
+ */
6462
+ async signInWithPasskey(
6463
+ credentials?: SignInWithPasskeyCredentials
6464
+ ): Promise<AuthPasskeyAuthenticationVerifyResponse> {
6465
+ assertPasskeyExperimentalEnabled(this.experimental)
6466
+ try {
6467
+ if (!browserSupportsWebAuthn()) {
6468
+ return this._returnResult({
6469
+ data: null,
6470
+ error: new AuthUnknownError('Browser does not support WebAuthn', null),
6471
+ })
6472
+ }
6473
+
6474
+ // 1. Get challenge options from server
6475
+ const { data: options, error: optionsError } = await this._startPasskeyAuthentication({
6476
+ options: { captchaToken: credentials?.options?.captchaToken },
6477
+ })
6478
+ if (optionsError || !options) {
6479
+ return this._returnResult({ data: null, error: optionsError })
6480
+ }
6481
+
6482
+ // 2. Deserialize and prompt user via browser WebAuthn API
6483
+ const publicKeyOptions = deserializeCredentialRequestOptions(options.options)
6484
+ const signal = credentials?.options?.signal ?? webAuthnAbortService.createNewAbortSignal()
6485
+ const { data: credential, error: credentialError } = await getCredential({
6486
+ publicKey: publicKeyOptions,
6487
+ signal,
6488
+ })
6489
+ if (credentialError || !credential) {
6490
+ return this._returnResult({
6491
+ data: null,
6492
+ error: credentialError ?? new AuthUnknownError('WebAuthn ceremony failed', null),
6493
+ })
6494
+ }
6495
+
6496
+ // 3. Serialize and verify with server
6497
+ const serialized = serializeCredentialRequestResponse(credential)
6498
+ return this._verifyPasskeyAuthentication({
6499
+ challengeId: options.challenge_id,
6500
+ credential: serialized,
6501
+ })
6502
+ } catch (error) {
6503
+ if (isAuthError(error)) {
6504
+ return this._returnResult({ data: null, error })
6505
+ }
6506
+ throw error
6507
+ }
6508
+ }
6509
+
6510
+ /**
6511
+ * Register a passkey for the current authenticated user. Handles the full WebAuthn ceremony:
6512
+ * 1. Fetches registration challenge from server
6513
+ * 2. Prompts user via navigator.credentials.create()
6514
+ * 3. Verifies credential with server
6515
+ *
6516
+ * Requires an active session. Requires `auth.experimental.passkey: true`.
6517
+ *
6518
+ * @category Auth
6519
+ */
6520
+ async registerPasskey(
6521
+ credentials?: RegisterPasskeyCredentials
6522
+ ): Promise<AuthPasskeyRegistrationVerifyResponse> {
6523
+ assertPasskeyExperimentalEnabled(this.experimental)
6524
+ try {
6525
+ if (!browserSupportsWebAuthn()) {
6526
+ return this._returnResult({
6527
+ data: null,
6528
+ error: new AuthUnknownError('Browser does not support WebAuthn', null),
6529
+ })
6530
+ }
6531
+
6532
+ // 1. Get challenge options from server
6533
+ const { data: options, error: optionsError } = await this._startPasskeyRegistration()
6534
+ if (optionsError || !options) {
6535
+ return this._returnResult({ data: null, error: optionsError })
6536
+ }
6537
+
6538
+ // 2. Deserialize and prompt user via browser WebAuthn API
6539
+ const publicKeyOptions = deserializeCredentialCreationOptions(options.options)
6540
+ const signal = credentials?.options?.signal ?? webAuthnAbortService.createNewAbortSignal()
6541
+ const { data: credential, error: credentialError } = await createCredential({
6542
+ publicKey: publicKeyOptions,
6543
+ signal,
6544
+ })
6545
+ if (credentialError || !credential) {
6546
+ return this._returnResult({
6547
+ data: null,
6548
+ error: credentialError ?? new AuthUnknownError('WebAuthn ceremony failed', null),
6549
+ })
6550
+ }
6551
+
6552
+ // 3. Serialize and verify with server
6553
+ const serialized = serializeCredentialCreationResponse(credential)
6554
+ return this._verifyPasskeyRegistration({
6555
+ challengeId: options.challenge_id,
6556
+ credential: serialized,
6557
+ })
6558
+ } catch (error) {
6559
+ if (isAuthError(error)) {
6560
+ return this._returnResult({ data: null, error })
6561
+ }
6562
+ throw error
6563
+ }
6564
+ }
6565
+
6566
+ /**
6567
+ * Start passkey registration for the current authenticated user.
6568
+ * Returns WebAuthn credential creation options to pass to navigator.credentials.create().
6569
+ */
6570
+ private async _startPasskeyRegistration(): Promise<AuthPasskeyRegistrationOptionsResponse> {
6571
+ assertPasskeyExperimentalEnabled(this.experimental)
6572
+ try {
6573
+ return await this._useSession(async (result) => {
6574
+ const {
6575
+ data: { session },
6576
+ error: sessionError,
6577
+ } = result
6578
+ if (sessionError) {
6579
+ return this._returnResult({ data: null, error: sessionError })
6580
+ }
6581
+ if (!session) {
6582
+ return this._returnResult({ data: null, error: new AuthSessionMissingError() })
6583
+ }
6584
+ const { data, error } = await _request(
6585
+ this.fetch,
6586
+ 'POST',
6587
+ `${this.url}/passkeys/registration/options`,
6588
+ {
6589
+ headers: this.headers,
6590
+ jwt: session.access_token,
6591
+ body: {},
6592
+ }
6593
+ )
6594
+ if (error) {
6595
+ return this._returnResult({ data: null, error })
6596
+ }
6597
+ return this._returnResult({ data, error: null })
6598
+ })
6599
+ } catch (error) {
6600
+ if (isAuthError(error)) {
6601
+ return this._returnResult({ data: null, error })
6602
+ }
6603
+ throw error
6604
+ }
6605
+ }
6606
+
6607
+ /**
6608
+ * Verify passkey registration with the credential response.
6609
+ * The credentialResponse should be the serialized output of navigator.credentials.create().
6610
+ */
6611
+ private async _verifyPasskeyRegistration(
6612
+ params: VerifyPasskeyRegistrationParams
6613
+ ): Promise<AuthPasskeyRegistrationVerifyResponse> {
6614
+ assertPasskeyExperimentalEnabled(this.experimental)
6615
+ try {
6616
+ return await this._useSession(async (result) => {
6617
+ const {
6618
+ data: { session },
6619
+ error: sessionError,
6620
+ } = result
6621
+ if (sessionError) {
6622
+ return this._returnResult({ data: null, error: sessionError })
6623
+ }
6624
+ if (!session) {
6625
+ return this._returnResult({ data: null, error: new AuthSessionMissingError() })
6626
+ }
6627
+ const { data, error } = await _request(
6628
+ this.fetch,
6629
+ 'POST',
6630
+ `${this.url}/passkeys/registration/verify`,
6631
+ {
6632
+ headers: this.headers,
6633
+ jwt: session.access_token,
6634
+ body: {
6635
+ challenge_id: params.challengeId,
6636
+ credential: params.credential,
6637
+ },
6638
+ }
6639
+ )
6640
+ if (error) {
6641
+ return this._returnResult({ data: null, error })
6642
+ }
6643
+ return this._returnResult({ data, error: null })
6644
+ })
6645
+ } catch (error) {
6646
+ if (isAuthError(error)) {
6647
+ return this._returnResult({ data: null, error })
6648
+ }
6649
+ throw error
6650
+ }
6651
+ }
6652
+
6653
+ /**
6654
+ * Start passkey authentication.
6655
+ * Returns WebAuthn credential request options to pass to navigator.credentials.get().
6656
+ */
6657
+ private async _startPasskeyAuthentication(
6658
+ params?: StartPasskeyAuthenticationParams
6659
+ ): Promise<AuthPasskeyAuthenticationOptionsResponse> {
6660
+ assertPasskeyExperimentalEnabled(this.experimental)
6661
+ try {
6662
+ const { data, error } = await _request(
6663
+ this.fetch,
6664
+ 'POST',
6665
+ `${this.url}/passkeys/authentication/options`,
6666
+ {
6667
+ headers: this.headers,
6668
+ body: {
6669
+ gotrue_meta_security: { captcha_token: params?.options?.captchaToken },
6670
+ },
6671
+ }
6672
+ )
6673
+ if (error) {
6674
+ return this._returnResult({ data: null, error })
6675
+ }
6676
+ return this._returnResult({ data, error: null })
6677
+ } catch (error) {
6678
+ if (isAuthError(error)) {
6679
+ return this._returnResult({ data: null, error })
6680
+ }
6681
+ throw error
6682
+ }
6683
+ }
6684
+
6685
+ /**
6686
+ * Verify passkey authentication and create a session.
6687
+ * The credential should be the serialized output of navigator.credentials.get().
6688
+ */
6689
+ private async _verifyPasskeyAuthentication(
6690
+ params: VerifyPasskeyAuthenticationParams
6691
+ ): Promise<AuthPasskeyAuthenticationVerifyResponse> {
6692
+ assertPasskeyExperimentalEnabled(this.experimental)
6693
+ try {
6694
+ const { data, error } = await _request(
6695
+ this.fetch,
6696
+ 'POST',
6697
+ `${this.url}/passkeys/authentication/verify`,
6698
+ {
6699
+ headers: this.headers,
6700
+ body: {
6701
+ challenge_id: params.challengeId,
6702
+ credential: params.credential,
6703
+ },
6704
+ xform: _sessionResponse,
6705
+ }
6706
+ )
6707
+ if (error) {
6708
+ return this._returnResult({ data: null, error })
6709
+ }
6710
+ if (data.session) {
6711
+ await this._saveSession(data.session)
6712
+ await this._notifyAllSubscribers('SIGNED_IN', data.session)
6713
+ }
6714
+ return this._returnResult({ data, error: null })
6715
+ } catch (error) {
6716
+ if (isAuthError(error)) {
6717
+ return this._returnResult({ data: null, error })
6718
+ }
6719
+ throw error
6720
+ }
6721
+ }
6722
+
6723
+ /**
6724
+ * List all passkeys for the current user.
6725
+ */
6726
+ private async _listPasskeys(): Promise<AuthPasskeyListResponse> {
6727
+ assertPasskeyExperimentalEnabled(this.experimental)
6728
+ try {
6729
+ return await this._useSession(async (result) => {
6730
+ const {
6731
+ data: { session },
6732
+ error: sessionError,
6733
+ } = result
6734
+ if (sessionError) {
6735
+ return this._returnResult({ data: null, error: sessionError })
6736
+ }
6737
+ if (!session) {
6738
+ return this._returnResult({ data: null, error: new AuthSessionMissingError() })
6739
+ }
6740
+ const { data, error } = await _request(this.fetch, 'GET', `${this.url}/passkeys`, {
6741
+ headers: this.headers,
6742
+ jwt: session.access_token,
6743
+ xform: (data: any) => ({ data, error: null }),
6744
+ })
6745
+ if (error) {
6746
+ return this._returnResult({ data: null, error })
6747
+ }
6748
+ return this._returnResult({ data, error: null })
6749
+ })
6750
+ } catch (error) {
6751
+ if (isAuthError(error)) {
6752
+ return this._returnResult({ data: null, error })
6753
+ }
6754
+ throw error
6755
+ }
6756
+ }
6757
+
6758
+ /**
6759
+ * Update a passkey.
6760
+ */
6761
+ private async _updatePasskey(params: PasskeyUpdateParams): Promise<AuthPasskeyUpdateResponse> {
6762
+ assertPasskeyExperimentalEnabled(this.experimental)
6763
+ try {
6764
+ return await this._useSession(async (result) => {
6765
+ const {
6766
+ data: { session },
6767
+ error: sessionError,
6768
+ } = result
6769
+ if (sessionError) {
6770
+ return this._returnResult({ data: null, error: sessionError })
6771
+ }
6772
+ if (!session) {
6773
+ return this._returnResult({ data: null, error: new AuthSessionMissingError() })
6774
+ }
6775
+ const { data, error } = await _request(
6776
+ this.fetch,
6777
+ 'PATCH',
6778
+ `${this.url}/passkeys/${params.passkeyId}`,
6779
+ {
6780
+ headers: this.headers,
6781
+ jwt: session.access_token,
6782
+ body: { friendly_name: params.friendlyName },
6783
+ }
6784
+ )
6785
+ if (error) {
6786
+ return this._returnResult({ data: null, error })
6787
+ }
6788
+ return this._returnResult({ data, error: null })
6789
+ })
6790
+ } catch (error) {
6791
+ if (isAuthError(error)) {
6792
+ return this._returnResult({ data: null, error })
6793
+ }
6794
+ throw error
6795
+ }
6796
+ }
6797
+
6798
+ /**
6799
+ * Delete a passkey.
6800
+ */
6801
+ private async _deletePasskey(params: PasskeyDeleteParams): Promise<AuthPasskeyDeleteResponse> {
6802
+ assertPasskeyExperimentalEnabled(this.experimental)
6803
+ try {
6804
+ return await this._useSession(async (result) => {
6805
+ const {
6806
+ data: { session },
6807
+ error: sessionError,
6808
+ } = result
6809
+ if (sessionError) {
6810
+ return this._returnResult({ data: null, error: sessionError })
6811
+ }
6812
+ if (!session) {
6813
+ return this._returnResult({ data: null, error: new AuthSessionMissingError() })
6814
+ }
6815
+ const { error } = await _request(
6816
+ this.fetch,
6817
+ 'DELETE',
6818
+ `${this.url}/passkeys/${params.passkeyId}`,
6819
+ {
6820
+ headers: this.headers,
6821
+ jwt: session.access_token,
6822
+ noResolveJson: true,
6823
+ }
6824
+ )
6825
+ if (error) {
6826
+ return this._returnResult({ data: null, error })
6827
+ }
6828
+ return this._returnResult({ data: null, error: null })
6829
+ })
6830
+ } catch (error) {
6831
+ if (isAuthError(error)) {
6832
+ return this._returnResult({ data: null, error })
6833
+ }
6834
+ throw error
6835
+ }
6836
+ }
5901
6837
  }