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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +11 -0
  2. package/dist/main/GoTrueClient.d.ts +68 -14
  3. package/dist/main/GoTrueClient.d.ts.map +1 -1
  4. package/dist/main/GoTrueClient.js +331 -107
  5. package/dist/main/GoTrueClient.js.map +1 -1
  6. package/dist/main/lib/errors.d.ts +24 -0
  7. package/dist/main/lib/errors.d.ts.map +1 -1
  8. package/dist/main/lib/errors.js +31 -1
  9. package/dist/main/lib/errors.js.map +1 -1
  10. package/dist/main/lib/locks.d.ts +28 -34
  11. package/dist/main/lib/locks.d.ts.map +1 -1
  12. package/dist/main/lib/locks.js +28 -34
  13. package/dist/main/lib/locks.js.map +1 -1
  14. package/dist/main/lib/types.d.ts +16 -27
  15. package/dist/main/lib/types.d.ts.map +1 -1
  16. package/dist/main/lib/types.js.map +1 -1
  17. package/dist/main/lib/version.d.ts +1 -1
  18. package/dist/main/lib/version.d.ts.map +1 -1
  19. package/dist/main/lib/version.js +1 -1
  20. package/dist/main/lib/version.js.map +1 -1
  21. package/dist/module/GoTrueClient.d.ts +68 -14
  22. package/dist/module/GoTrueClient.d.ts.map +1 -1
  23. package/dist/module/GoTrueClient.js +333 -109
  24. package/dist/module/GoTrueClient.js.map +1 -1
  25. package/dist/module/lib/errors.d.ts +24 -0
  26. package/dist/module/lib/errors.d.ts.map +1 -1
  27. package/dist/module/lib/errors.js +28 -0
  28. package/dist/module/lib/errors.js.map +1 -1
  29. package/dist/module/lib/locks.d.ts +28 -34
  30. package/dist/module/lib/locks.d.ts.map +1 -1
  31. package/dist/module/lib/locks.js +28 -34
  32. package/dist/module/lib/locks.js.map +1 -1
  33. package/dist/module/lib/types.d.ts +16 -27
  34. package/dist/module/lib/types.d.ts.map +1 -1
  35. package/dist/module/lib/types.js.map +1 -1
  36. package/dist/module/lib/version.d.ts +1 -1
  37. package/dist/module/lib/version.d.ts.map +1 -1
  38. package/dist/module/lib/version.js +1 -1
  39. package/dist/module/lib/version.js.map +1 -1
  40. package/dist/tsconfig.module.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/migrations/README.md +25 -0
  43. package/migrations/lockless-coordination.md +89 -0
  44. package/package.json +4 -2
  45. package/src/GoTrueClient.ts +397 -137
  46. package/src/lib/errors.ts +32 -0
  47. package/src/lib/locks.ts +29 -34
  48. package/src/lib/types.ts +16 -27
  49. package/src/lib/version.ts +1 -1
@@ -1,10 +1,10 @@
1
1
  import GoTrueAdminApi from './GoTrueAdminApi';
2
2
  import { AUTO_REFRESH_TICK_DURATION_MS, AUTO_REFRESH_TICK_THRESHOLD, DEFAULT_HEADERS, EXPIRY_MARGIN_MS, GOTRUE_URL, JWKS_TTL, STORAGE_KEY, } from './lib/constants';
3
- import { AuthImplicitGrantRedirectError, AuthInvalidCredentialsError, AuthInvalidJwtError, AuthInvalidTokenResponseError, AuthPKCECodeVerifierMissingError, AuthPKCEGrantCodeExchangeError, AuthSessionMissingError, AuthUnknownError, isAuthApiError, isAuthError, isAuthImplicitGrantRedirectError, isAuthRetryableFetchError, isAuthSessionMissingError, } from './lib/errors';
3
+ import { AuthImplicitGrantRedirectError, AuthInvalidCredentialsError, AuthInvalidJwtError, AuthInvalidTokenResponseError, AuthPKCECodeVerifierMissingError, AuthPKCEGrantCodeExchangeError, AuthRefreshDiscardedError, AuthSessionMissingError, AuthUnknownError, isAuthApiError, isAuthError, isAuthImplicitGrantRedirectError, isAuthRefreshDiscardedError, isAuthRetryableFetchError, isAuthSessionMissingError, } from './lib/errors';
4
4
  import { _request, _sessionResponse, _sessionResponsePassword, _ssoResponse, _userResponse, } from './lib/fetch';
5
5
  import { assertPasskeyExperimentalEnabled, decodeJWT, deepClone, Deferred, generateCallbackId, getAlgorithm, getCodeChallengeAndMethod, getItemAsync, insecureUserWarningProxy, isBrowser, parseParametersFromURL, removeItemAsync, resolveFetch, retryable, setItemAsync, sleep, supportsLocalStorage, userNotAvailableProxy, validateExp, } from './lib/helpers';
6
6
  import { memoryLocalStorageAdapter } from './lib/local-storage';
7
- import { LockAcquireTimeoutError, navigatorLock } from './lib/locks';
7
+ import { LockAcquireTimeoutError } from './lib/locks';
8
8
  import { polyfillGlobalThis } from './lib/polyfills';
9
9
  import { version } from './lib/version';
10
10
  import { bytesToBase64URL, stringToUint8Array } from './lib/base64url';
@@ -22,10 +22,16 @@ const DEFAULT_OPTIONS = {
22
22
  debug: false,
23
23
  hasCustomAuthorizationHeader: false,
24
24
  throwOnError: false,
25
- lockAcquireTimeout: 5000, // 5 seconds
25
+ lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
26
26
  skipAutoInitialize: false,
27
27
  experimental: {},
28
28
  };
29
+ /**
30
+ * No-op lock used internally as a placeholder. Kept so older test setups that
31
+ * inject this exact reference do not break; new code never sees it because
32
+ * `this.lock` stays `null` when no custom lock is supplied (lockless path).
33
+ * TODO(v3): remove with the legacy lock path.
34
+ */
29
35
  async function lockNoOp(name, acquireTimeout, fn) {
30
36
  return await fn();
31
37
  }
@@ -79,7 +85,7 @@ class GoTrueClient {
79
85
  * ```
80
86
  */
81
87
  constructor(options) {
82
- var _a, _b, _c, _d;
88
+ var _a, _b, _c;
83
89
  /**
84
90
  * @experimental
85
91
  */
@@ -90,6 +96,14 @@ class GoTrueClient {
90
96
  this.autoRefreshTickTimeout = null;
91
97
  this.visibilityChangedCallback = null;
92
98
  this.refreshingDeferred = null;
99
+ /**
100
+ * Monotonic counter incremented at the top of `_removeSession`, before any
101
+ * `await`. The commit guard inside `_callRefreshToken` captures this value
102
+ * before `_saveSession` and re-checks it after, so a `signOut` that
103
+ * interleaves inside `_saveSession`'s storage-write awaits is still caught
104
+ * (the post-fetch storage snapshot alone misses that window).
105
+ */
106
+ this._sessionRemovalEpoch = 0;
93
107
  /**
94
108
  * Keeps track of the async client initialization.
95
109
  * When null or not yet resolved the auth state is `unknown`
@@ -100,6 +114,13 @@ class GoTrueClient {
100
114
  this.detectSessionInUrl = true;
101
115
  this.hasCustomAuthorizationHeader = false;
102
116
  this.suppressGetSessionWarning = false;
117
+ /**
118
+ * Custom lock function passed via `settings.lock`. When non-null, every auth
119
+ * operation runs inside `_acquireLock`. When null (the default), the client
120
+ * uses its lockless coordination (refresh single-flight + commit guard).
121
+ * TODO(v3): remove along with the legacy lock path.
122
+ */
123
+ this.lock = null;
103
124
  this.lockAcquired = false;
104
125
  this.pendingInLock = [];
105
126
  /**
@@ -134,21 +155,22 @@ class GoTrueClient {
134
155
  this.url = settings.url;
135
156
  this.headers = settings.headers;
136
157
  this.fetch = resolveFetch(settings.fetch);
137
- this.lock = settings.lock || lockNoOp;
138
158
  this.detectSessionInUrl = settings.detectSessionInUrl;
139
159
  this.flowType = settings.flowType;
140
160
  this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader;
141
161
  this.throwOnError = settings.throwOnError;
162
+ // Always wire `lockAcquireTimeout` even on the lockless path: consumers
163
+ // (including supabase-js tests) read it off the client to verify option
164
+ // flow-through.
142
165
  this.lockAcquireTimeout = settings.lockAcquireTimeout;
143
- if (settings.lock) {
166
+ // TODO(v3): remove. Legacy opt-in path preserved for backwards
167
+ // compatibility with callers passing a custom `lock` (typically React
168
+ // Native `processLock` or Node multi-process setups). When `settings.lock`
169
+ // is null the client uses its lockless coordination — no `navigator.locks`
170
+ // by default, no implicit `processLock`.
171
+ if (settings.lock != null) {
144
172
  this.lock = settings.lock;
145
173
  }
146
- else if (this.persistSession && isBrowser() && ((_c = globalThis === null || globalThis === void 0 ? void 0 : globalThis.navigator) === null || _c === void 0 ? void 0 : _c.locks)) {
147
- this.lock = navigatorLock;
148
- }
149
- else {
150
- this.lock = lockNoOp;
151
- }
152
174
  if (!this.jwks) {
153
175
  this.jwks = { keys: [] };
154
176
  this.jwks_cached_at = Number.MIN_SAFE_INTEGER;
@@ -207,7 +229,7 @@ class GoTrueClient {
207
229
  catch (e) {
208
230
  console.error('Failed to create a new BroadcastChannel, multi-tab state changes will not be available', e);
209
231
  }
210
- (_d = this.broadcastChannel) === null || _d === void 0 ? void 0 : _d.addEventListener('message', async (event) => {
232
+ (_c = this.broadcastChannel) === null || _c === void 0 ? void 0 : _c.addEventListener('message', async (event) => {
211
233
  this._debug('received broadcast notification from other tab or client', event);
212
234
  try {
213
235
  await this._notifyAllSubscribers(event.data.event, event.data.session, false); // broadcast = false so we don't get an endless loop of messages
@@ -265,9 +287,13 @@ class GoTrueClient {
265
287
  return await this.initializePromise;
266
288
  }
267
289
  this.initializePromise = (async () => {
268
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
269
- return await this._initialize();
270
- });
290
+ if (this.lock != null) {
291
+ // TODO(v3): remove legacy lock path
292
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
293
+ return await this._initialize();
294
+ });
295
+ }
296
+ return await this._initialize();
271
297
  })();
272
298
  return await this.initializePromise;
273
299
  }
@@ -1118,9 +1144,13 @@ class GoTrueClient {
1118
1144
  */
1119
1145
  async exchangeCodeForSession(authCode) {
1120
1146
  await this.initializePromise;
1121
- return this._acquireLock(this.lockAcquireTimeout, async () => {
1122
- return this._exchangeCodeForSession(authCode);
1123
- });
1147
+ if (this.lock != null) {
1148
+ // TODO(v3): remove legacy lock path
1149
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1150
+ return this._exchangeCodeForSession(authCode);
1151
+ });
1152
+ }
1153
+ return this._exchangeCodeForSession(authCode);
1124
1154
  }
1125
1155
  /**
1126
1156
  * Signs in a user by verifying a message signed by the user's private key.
@@ -2024,9 +2054,13 @@ class GoTrueClient {
2024
2054
  */
2025
2055
  async reauthenticate() {
2026
2056
  await this.initializePromise;
2027
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2028
- return await this._reauthenticate();
2029
- });
2057
+ if (this.lock != null) {
2058
+ // TODO(v3): remove legacy lock path
2059
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2060
+ return await this._reauthenticate();
2061
+ });
2062
+ }
2063
+ return await this._reauthenticate();
2030
2064
  }
2031
2065
  async _reauthenticate() {
2032
2066
  try {
@@ -2169,7 +2203,7 @@ class GoTrueClient {
2169
2203
  * - If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
2170
2204
  * - 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.
2171
2205
  * - **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.
2172
- * - 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.
2206
+ * - 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.
2173
2207
  *
2174
2208
  * @example Get the session data
2175
2209
  * ```js
@@ -2236,15 +2270,24 @@ class GoTrueClient {
2236
2270
  */
2237
2271
  async getSession() {
2238
2272
  await this.initializePromise;
2239
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2240
- return this._useSession(async (result) => {
2241
- return result;
2273
+ if (this.lock != null) {
2274
+ // TODO(v3): remove legacy lock path
2275
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2276
+ return this._useSession(async (result) => {
2277
+ return result;
2278
+ });
2242
2279
  });
2280
+ }
2281
+ return await this._useSession(async (result) => {
2282
+ return result;
2243
2283
  });
2244
- return result;
2245
2284
  }
2246
2285
  /**
2247
2286
  * Acquires a global lock based on the storage key.
2287
+ *
2288
+ * TODO(v3): remove along with the legacy lock path. Only called when
2289
+ * `this.lock` is non-null (custom lock supplied via constructor). The
2290
+ * default lockless path bypasses this entirely.
2248
2291
  */
2249
2292
  async _acquireLock(acquireTimeout, fn) {
2250
2293
  this._debug('#_acquireLock', 'begin', acquireTimeout);
@@ -2300,15 +2343,17 @@ class GoTrueClient {
2300
2343
  }
2301
2344
  }
2302
2345
  /**
2303
- * Use instead of {@link #getSession} inside the library. It is
2304
- * semantically usually what you want, as getting a session involves some
2305
- * processing afterwards that requires only one client operating on the
2306
- * session at once across multiple tabs or processes.
2346
+ * Use instead of {@link #getSession} inside the library. Loads the session
2347
+ * via `__loadSession` (which may trigger a refresh if the access token is
2348
+ * within the expiry margin) and runs `fn` with the result.
2307
2349
  */
2308
2350
  async _useSession(fn) {
2309
2351
  this._debug('#_useSession', 'begin');
2310
2352
  try {
2311
- // the use of __loadSession here is the only correct use of the function!
2353
+ // Concurrent callers may both reach __loadSession; storage reads are
2354
+ // idempotent, and the only write path inside it (refresh) is
2355
+ // single-flighted downstream by `refreshingDeferred` in
2356
+ // `_callRefreshToken`. No serialization is needed at this layer.
2312
2357
  const result = await this.__loadSession();
2313
2358
  return await fn(result);
2314
2359
  }
@@ -2323,7 +2368,8 @@ class GoTrueClient {
2323
2368
  */
2324
2369
  async __loadSession() {
2325
2370
  this._debug('#__loadSession()', 'begin');
2326
- if (!this.lockAcquired) {
2371
+ if (this.lock != null && !this.lockAcquired) {
2372
+ // TODO(v3): remove. Only meaningful on the legacy lock path.
2327
2373
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack);
2328
2374
  }
2329
2375
  try {
@@ -2466,9 +2512,16 @@ class GoTrueClient {
2466
2512
  return await this._getUser(jwt);
2467
2513
  }
2468
2514
  await this.initializePromise;
2469
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2470
- return await this._getUser();
2471
- });
2515
+ let result;
2516
+ if (this.lock != null) {
2517
+ // TODO(v3): remove legacy lock path
2518
+ result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2519
+ return await this._getUser();
2520
+ });
2521
+ }
2522
+ else {
2523
+ result = await this._getUser();
2524
+ }
2472
2525
  if (result.data.user) {
2473
2526
  this.suppressGetSessionWarning = true;
2474
2527
  }
@@ -2629,9 +2682,13 @@ class GoTrueClient {
2629
2682
  */
2630
2683
  async updateUser(attributes, options = {}) {
2631
2684
  await this.initializePromise;
2632
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2633
- return await this._updateUser(attributes, options);
2634
- });
2685
+ if (this.lock != null) {
2686
+ // TODO(v3): remove legacy lock path
2687
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2688
+ return await this._updateUser(attributes, options);
2689
+ });
2690
+ }
2691
+ return await this._updateUser(attributes, options);
2635
2692
  }
2636
2693
  async _updateUser(attributes, options = {}) {
2637
2694
  try {
@@ -2800,9 +2857,13 @@ class GoTrueClient {
2800
2857
  */
2801
2858
  async setSession(currentSession) {
2802
2859
  await this.initializePromise;
2803
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2804
- return await this._setSession(currentSession);
2805
- });
2860
+ if (this.lock != null) {
2861
+ // TODO(v3): remove legacy lock path
2862
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2863
+ return await this._setSession(currentSession);
2864
+ });
2865
+ }
2866
+ return await this._setSession(currentSession);
2806
2867
  }
2807
2868
  async _setSession(currentSession) {
2808
2869
  try {
@@ -2980,9 +3041,13 @@ class GoTrueClient {
2980
3041
  */
2981
3042
  async refreshSession(currentSession) {
2982
3043
  await this.initializePromise;
2983
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2984
- return await this._refreshSession(currentSession);
2985
- });
3044
+ if (this.lock != null) {
3045
+ // TODO(v3): remove legacy lock path
3046
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3047
+ return await this._refreshSession(currentSession);
3048
+ });
3049
+ }
3050
+ return await this._refreshSession(currentSession);
2986
3051
  }
2987
3052
  async _refreshSession(currentSession) {
2988
3053
  try {
@@ -3172,9 +3237,13 @@ class GoTrueClient {
3172
3237
  */
3173
3238
  async signOut(options = { scope: 'global' }) {
3174
3239
  await this.initializePromise;
3175
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3176
- return await this._signOut(options);
3177
- });
3240
+ if (this.lock != null) {
3241
+ // TODO(v3): remove legacy lock path
3242
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3243
+ return await this._signOut(options);
3244
+ });
3245
+ }
3246
+ return await this._signOut(options);
3178
3247
  }
3179
3248
  async _signOut({ scope } = { scope: 'global' }) {
3180
3249
  return await this._useSession(async (result) => {
@@ -3210,18 +3279,8 @@ class GoTrueClient {
3210
3279
  * - Subscribes to important events occurring on the user's session.
3211
3280
  * - Use on the frontend/client. It is less useful on the server.
3212
3281
  * - 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.
3213
- * - **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.
3214
- * - Avoid using `async` functions as callbacks.
3215
- * - Limit the number of `await` calls in `async` callbacks.
3216
- * - 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:
3217
- * ```js
3218
- * supabase.auth.onAuthStateChange((event, session) => {
3219
- * setTimeout(async () => {
3220
- * // await on other Supabase function here
3221
- * // this runs right after the callback has finished
3222
- * }, 0)
3223
- * })
3224
- * ```
3282
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
3283
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3225
3284
  * - Emitted events:
3226
3285
  * - `INITIAL_SESSION`
3227
3286
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -3404,9 +3463,15 @@ class GoTrueClient {
3404
3463
  this.stateChangeEmitters.set(id, subscription);
3405
3464
  (async () => {
3406
3465
  await this.initializePromise;
3407
- await this._acquireLock(this.lockAcquireTimeout, async () => {
3408
- this._emitInitialSession(id);
3409
- });
3466
+ if (this.lock != null) {
3467
+ // TODO(v3): remove legacy lock path
3468
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
3469
+ this._emitInitialSession(id);
3470
+ });
3471
+ }
3472
+ else {
3473
+ await this._emitInitialSession(id);
3474
+ }
3410
3475
  })();
3411
3476
  return { data: { subscription } };
3412
3477
  }
@@ -3748,7 +3813,10 @@ class GoTrueClient {
3748
3813
  * @param refreshToken A valid refresh token that was returned on login.
3749
3814
  */
3750
3815
  async _refreshAccessToken(refreshToken) {
3751
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`;
3816
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
3817
+ // fragment of the token in the debug tag, even when `debug: true` is
3818
+ // enabled (logs may be forwarded to third-party services).
3819
+ const debugName = `#_refreshAccessToken()`;
3752
3820
  this._debug(debugName, 'begin');
3753
3821
  try {
3754
3822
  const startedAt = Date.now();
@@ -3855,10 +3923,18 @@ class GoTrueClient {
3855
3923
  if (this.autoRefreshToken && currentSession.refresh_token) {
3856
3924
  const { error } = await this._callRefreshToken(currentSession.refresh_token);
3857
3925
  if (error) {
3858
- console.error(error);
3859
- if (!isAuthRetryableFetchError(error)) {
3860
- this._debug(debugName, 'refresh failed with a non-retryable error, removing the session', error);
3861
- await this._removeSession();
3926
+ // AuthRefreshDiscardedError means a concurrent signOut already
3927
+ // cleared storage and fired SIGNED_OUT. Don't run _removeSession
3928
+ // again here, or we'll emit a duplicate SIGNED_OUT.
3929
+ if (isAuthRefreshDiscardedError(error)) {
3930
+ this._debug(debugName, 'refresh discarded by commit guard', error);
3931
+ }
3932
+ else {
3933
+ console.error(error);
3934
+ if (!isAuthRetryableFetchError(error)) {
3935
+ this._debug(debugName, 'refresh failed with a non-retryable error, removing the session', error);
3936
+ await this._removeSession();
3937
+ }
3862
3938
  }
3863
3939
  }
3864
3940
  }
@@ -3907,16 +3983,69 @@ class GoTrueClient {
3907
3983
  if (this.refreshingDeferred) {
3908
3984
  return this.refreshingDeferred.promise;
3909
3985
  }
3910
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`;
3986
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
3987
+ // fragment of the token in the debug tag, even when `debug: true` is
3988
+ // enabled (logs may be forwarded to third-party services).
3989
+ const debugName = `#_callRefreshToken()`;
3911
3990
  this._debug(debugName, 'begin');
3912
3991
  try {
3913
3992
  this.refreshingDeferred = new Deferred();
3993
+ // Snapshot storage before the fetch. The commit guard discards the
3994
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
3995
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
3996
+ // another tab's refresh rewrote the slot. Callers passing
3997
+ // externally-sourced tokens (SSR cookie handoff, multi-account
3998
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
3999
+ // start from a null snapshot OR from a non-null snapshot whose
4000
+ // refresh_token differs from the one they're hydrating; in both
4001
+ // cases the guard fires only when storage was *modified between
4002
+ // snapshots*, not when the input token disagrees with what's stored.
4003
+ const storedAtStart = (await getItemAsync(this.storage, this.storageKey));
3914
4004
  const { data, error } = await this._refreshAccessToken(refreshToken);
3915
4005
  if (error)
3916
4006
  throw error;
3917
4007
  if (!data.session)
3918
4008
  throw new AuthSessionMissingError();
4009
+ const storedAfter = (await getItemAsync(this.storage, this.storageKey));
4010
+ const storageChangedUnderUs = storedAtStart !== null &&
4011
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token);
4012
+ if (storageChangedUnderUs) {
4013
+ this._debug(debugName, 'commit guard: storage changed since refresh started, discarding rotated tokens', {
4014
+ // Presence indicators only — never log refresh token fragments,
4015
+ // even partial. Logs may be forwarded to third-party services.
4016
+ startedWith: 'present',
4017
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4018
+ });
4019
+ const discarded = {
4020
+ data: null,
4021
+ error: new AuthRefreshDiscardedError(),
4022
+ };
4023
+ this.refreshingDeferred.resolve(discarded);
4024
+ return discarded;
4025
+ }
4026
+ // Second leg of the commit guard: close the TOCTOU window between the
4027
+ // synchronous `storageChangedUnderUs` check and the actual storage
4028
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4029
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4030
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4031
+ // the save and re-check after; if it advanced, undo the write directly
4032
+ // (do NOT call `_removeSession` — that would emit a duplicate
4033
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4034
+ const epochBeforeSave = this._sessionRemovalEpoch;
3919
4035
  await this._saveSession(data.session);
4036
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4037
+ this._debug(debugName, 'commit guard (post-save): _removeSession ran during _saveSession, undoing write');
4038
+ await removeItemAsync(this.storage, this.storageKey);
4039
+ if (this.userStorage) {
4040
+ await removeItemAsync(this.userStorage, this.storageKey + '-user');
4041
+ }
4042
+ const discarded = {
4043
+ data: null,
4044
+ error: new AuthRefreshDiscardedError(),
4045
+ };
4046
+ this.refreshingDeferred.resolve(discarded);
4047
+ return discarded;
4048
+ }
3920
4049
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session);
3921
4050
  const result = { data: data.session, error: null };
3922
4051
  this.refreshingDeferred.resolve(result);
@@ -4010,6 +4139,11 @@ class GoTrueClient {
4010
4139
  }
4011
4140
  }
4012
4141
  async _removeSession() {
4142
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
4143
+ // post-save check sees the increment whenever this method has started —
4144
+ // even if it hasn't finished. Pairs with the epoch check in
4145
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
4146
+ this._sessionRemovalEpoch += 1;
4013
4147
  this._debug('#_removeSession()');
4014
4148
  this.suppressGetSessionWarning = false;
4015
4149
  await removeItemAsync(this.storage, this.storageKey);
@@ -4180,47 +4314,122 @@ class GoTrueClient {
4180
4314
  this._removeVisibilityChangedCallback();
4181
4315
  await this._stopAutoRefresh();
4182
4316
  }
4317
+ /**
4318
+ * Tears down the client's background work: stops the auto-refresh interval,
4319
+ * removes the `visibilitychange` listener, closes the cross-tab
4320
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
4321
+ *
4322
+ * Call this from cleanup hooks when the client is being replaced before
4323
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
4324
+ * cases. Any in-flight `fetch` calls continue to completion and may still
4325
+ * write to storage; dispose doesn't abort them or erase storage.
4326
+ *
4327
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
4328
+ * disposed instance can still persist a rotated session to storage after
4329
+ * `dispose()` returns. A subsequent `createClient` against the same
4330
+ * `storageKey` will pick up that session on its next read. If you need
4331
+ * strict isolation between client lifecycles, await any pending auth
4332
+ * operation before calling `dispose()` (or change the `storageKey` for
4333
+ * the replacement client).
4334
+ *
4335
+ * Safe to call repeatedly.
4336
+ *
4337
+ * @category Auth
4338
+ *
4339
+ * @example Cleanup on React unmount
4340
+ * ```ts
4341
+ * useEffect(() => {
4342
+ * const client = createClient(...)
4343
+ * return () => { client.auth.dispose() }
4344
+ * }, [])
4345
+ * ```
4346
+ */
4347
+ async dispose() {
4348
+ var _a;
4349
+ this._removeVisibilityChangedCallback();
4350
+ await this._stopAutoRefresh();
4351
+ (_a = this.broadcastChannel) === null || _a === void 0 ? void 0 : _a.close();
4352
+ this.broadcastChannel = null;
4353
+ this.stateChangeEmitters.clear();
4354
+ }
4183
4355
  /**
4184
4356
  * Runs the auto refresh token tick.
4185
4357
  */
4186
4358
  async _autoRefreshTokenTick() {
4187
4359
  this._debug('#_autoRefreshTokenTick()', 'begin');
4188
- try {
4189
- await this._acquireLock(0, async () => {
4190
- try {
4191
- const now = Date.now();
4360
+ if (this.lock != null) {
4361
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
4362
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
4363
+ // that's the fail-fast skip path that lets the tick bail out instead
4364
+ // of queuing behind a long-running operation.
4365
+ try {
4366
+ await this._acquireLock(0, async () => {
4192
4367
  try {
4193
- return await this._useSession(async (result) => {
4194
- const { data: { session }, } = result;
4195
- if (!session || !session.refresh_token || !session.expires_at) {
4196
- this._debug('#_autoRefreshTokenTick()', 'no session');
4197
- return;
4198
- }
4199
- // session will expire in this many ticks (or has already expired if <= 0)
4200
- const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS);
4201
- this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4202
- if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
4203
- await this._callRefreshToken(session.refresh_token);
4204
- }
4205
- });
4368
+ const now = Date.now();
4369
+ try {
4370
+ return await this._useSession(async (result) => {
4371
+ const { data: { session }, } = result;
4372
+ if (!session || !session.refresh_token || !session.expires_at) {
4373
+ this._debug('#_autoRefreshTokenTick()', 'no session');
4374
+ return;
4375
+ }
4376
+ const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS);
4377
+ this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4378
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
4379
+ await this._callRefreshToken(session.refresh_token);
4380
+ }
4381
+ });
4382
+ }
4383
+ catch (e) {
4384
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4385
+ }
4206
4386
  }
4207
- catch (e) {
4208
- console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4387
+ finally {
4388
+ this._debug('#_autoRefreshTokenTick()', 'end');
4209
4389
  }
4390
+ });
4391
+ }
4392
+ catch (e) {
4393
+ if (e instanceof LockAcquireTimeoutError) {
4394
+ this._debug('auto refresh token tick lock not available');
4210
4395
  }
4211
- finally {
4212
- this._debug('#_autoRefreshTokenTick()', 'end');
4396
+ else {
4397
+ throw e;
4213
4398
  }
4214
- });
4399
+ }
4400
+ return;
4215
4401
  }
4216
- catch (e) {
4217
- if (e instanceof LockAcquireTimeoutError) {
4218
- this._debug('auto refresh token tick lock not available');
4402
+ // Lockless default: skip if a refresh is already in flight.
4403
+ // `_callRefreshToken` also dedupes via the same field; this is just a
4404
+ // fast-path skip to avoid an unnecessary storage read.
4405
+ if (this.refreshingDeferred !== null) {
4406
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping');
4407
+ return;
4408
+ }
4409
+ try {
4410
+ const now = Date.now();
4411
+ try {
4412
+ await this._useSession(async (result) => {
4413
+ const { data: { session }, } = result;
4414
+ if (!session || !session.refresh_token || !session.expires_at) {
4415
+ this._debug('#_autoRefreshTokenTick()', 'no session');
4416
+ return;
4417
+ }
4418
+ // session will expire in this many ticks (or has already expired if <= 0)
4419
+ const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS);
4420
+ this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4421
+ if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
4422
+ await this._callRefreshToken(session.refresh_token);
4423
+ }
4424
+ });
4219
4425
  }
4220
- else {
4221
- throw e;
4426
+ catch (e) {
4427
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4222
4428
  }
4223
4429
  }
4430
+ finally {
4431
+ this._debug('#_autoRefreshTokenTick()', 'end');
4432
+ }
4224
4433
  }
4225
4434
  /**
4226
4435
  * Registers callbacks on the browser / platform, which in-turn run
@@ -4269,18 +4478,26 @@ class GoTrueClient {
4269
4478
  if (!calledFromInitialize) {
4270
4479
  // called when the visibility has changed, i.e. the browser
4271
4480
  // transitioned from hidden -> visible so we need to see if the session
4272
- // should be recovered immediately... but to do that we need to acquire
4273
- // the lock first asynchronously
4481
+ // should be recovered
4274
4482
  await this.initializePromise;
4275
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4483
+ if (this.lock != null) {
4484
+ // TODO(v3): remove legacy lock path
4485
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4486
+ if (document.visibilityState !== 'visible') {
4487
+ this._debug(methodName, 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting');
4488
+ return;
4489
+ }
4490
+ await this._recoverAndRefresh();
4491
+ });
4492
+ }
4493
+ else {
4276
4494
  if (document.visibilityState !== 'visible') {
4277
- this._debug(methodName, 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting');
4278
- // visibility has changed while waiting for the lock, abort
4495
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery');
4279
4496
  return;
4280
4497
  }
4281
4498
  // recover the session
4282
4499
  await this._recoverAndRefresh();
4283
- });
4500
+ }
4284
4501
  }
4285
4502
  }
4286
4503
  else if (document.visibilityState === 'hidden') {
@@ -4376,7 +4593,7 @@ class GoTrueClient {
4376
4593
  }
4377
4594
  }
4378
4595
  async _verify(params) {
4379
- return this._acquireLock(this.lockAcquireTimeout, async () => {
4596
+ const run = async () => {
4380
4597
  try {
4381
4598
  return await this._useSession(async (result) => {
4382
4599
  var _a;
@@ -4410,10 +4627,15 @@ class GoTrueClient {
4410
4627
  }
4411
4628
  throw error;
4412
4629
  }
4413
- });
4630
+ };
4631
+ if (this.lock != null) {
4632
+ // TODO(v3): remove legacy lock path
4633
+ return this._acquireLock(this.lockAcquireTimeout, run);
4634
+ }
4635
+ return run();
4414
4636
  }
4415
4637
  async _challenge(params) {
4416
- return this._acquireLock(this.lockAcquireTimeout, async () => {
4638
+ const run = async () => {
4417
4639
  try {
4418
4640
  return await this._useSession(async (result) => {
4419
4641
  var _a;
@@ -4453,14 +4675,17 @@ class GoTrueClient {
4453
4675
  }
4454
4676
  throw error;
4455
4677
  }
4456
- });
4678
+ };
4679
+ if (this.lock != null) {
4680
+ // TODO(v3): remove legacy lock path
4681
+ return this._acquireLock(this.lockAcquireTimeout, run);
4682
+ }
4683
+ return run();
4457
4684
  }
4458
4685
  /**
4459
4686
  * {@see GoTrueMFAApi#challengeAndVerify}
4460
4687
  */
4461
4688
  async _challengeAndVerify(params) {
4462
- // both _challenge and _verify independently acquire the lock, so no need
4463
- // to acquire it here
4464
4689
  const { data: challengeData, error: challengeError } = await this._challenge({
4465
4690
  factorId: params.factorId,
4466
4691
  });
@@ -4478,7 +4703,6 @@ class GoTrueClient {
4478
4703
  */
4479
4704
  async _listFactors() {
4480
4705
  var _a;
4481
- // use #getUser instead of #_getUser as the former acquires a lock
4482
4706
  const { data: { user }, error: userError, } = await this.getUser();
4483
4707
  if (userError) {
4484
4708
  return { data: null, error: userError };