@molecule/app-auth 3.0.2 → 3.2.1

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.
package/dist/client.js CHANGED
@@ -5,10 +5,128 @@
5
5
  *
6
6
  * @module
7
7
  */
8
+ import { HttpError, snapshotHttpErrorResponse, } from '@molecule/app-http';
8
9
  import { I18nError } from '@molecule/app-i18n';
9
- import { getLogger } from '@molecule/app-logger';
10
+ import { getLogger, redactCredentials, snapshotCredentials } from '@molecule/app-logger';
10
11
  import { createBrowserStorageAdapter } from './browser-storage.js';
11
12
  import { createTokenStorage, getTokenExpiration, isTokenExpired } from './token.js';
13
+ const HTTP_ERROR_BRAND = Symbol.for('@molecule/app-http/HttpError');
14
+ const ERROR_DATA_FIELDS = new Set(['name', 'message', 'stack', 'cause', 'errors']);
15
+ /** Copy only detached data onto the fresh error's public and diagnostic fields. */
16
+ const copyFailureSnapshot = (error, snapshot, preserveHttpMetadata = false) => {
17
+ for (const [key, value] of Object.entries(snapshot)) {
18
+ if (preserveHttpMetadata && (key === 'response' || key === 'config'))
19
+ continue;
20
+ Object.defineProperty(error, key, {
21
+ value,
22
+ enumerable: !ERROR_DATA_FIELDS.has(key),
23
+ configurable: true,
24
+ writable: true,
25
+ });
26
+ }
27
+ if (Object.getOwnPropertySymbols(snapshot).includes(HTTP_ERROR_BRAND) &&
28
+ Reflect.get(error, HTTP_ERROR_BRAND) !== true) {
29
+ Object.defineProperty(error, HTTP_ERROR_BRAND, { value: true });
30
+ }
31
+ };
32
+ const hasHttpErrorBrand = (failure) => failure !== null &&
33
+ (typeof failure === 'object' || typeof failure === 'function') &&
34
+ Object.getOwnPropertyDescriptor(failure, HTTP_ERROR_BRAND)?.value === true;
35
+ /** Capture every non-response field on the original error, preserving native stacks and receivers. */
36
+ const snapshotHttpFailureFields = (failure, response) => {
37
+ const originalFields = new Error();
38
+ const visited = new Set();
39
+ for (const key of [...ERROR_DATA_FIELDS, ...Reflect.ownKeys(failure)]) {
40
+ if (key === 'response')
41
+ continue;
42
+ if (typeof key === 'string') {
43
+ if (visited.has(key))
44
+ continue;
45
+ visited.add(key);
46
+ }
47
+ let value;
48
+ try {
49
+ value = Reflect.get(failure, key);
50
+ }
51
+ catch (_error) {
52
+ value = '[ACCESSOR_ERROR]';
53
+ }
54
+ Object.defineProperty(originalFields, key, {
55
+ value,
56
+ enumerable: true,
57
+ configurable: true,
58
+ writable: true,
59
+ });
60
+ }
61
+ const snapshot = snapshotCredentials(originalFields);
62
+ Object.defineProperty(snapshot, 'response', {
63
+ value: response,
64
+ enumerable: true,
65
+ configurable: true,
66
+ writable: true,
67
+ });
68
+ return snapshot;
69
+ };
70
+ /** Snapshot once, scrub the detached data, and never let the original failure escape. */
71
+ const snapshotAuthFailure = async (failure) => {
72
+ const httpFailure = hasHttpErrorBrand(failure);
73
+ let capturedResponse;
74
+ if (httpFailure) {
75
+ try {
76
+ const originalResponse = failure.response;
77
+ capturedResponse = originalResponse
78
+ ? await snapshotHttpErrorResponse(originalResponse)
79
+ : undefined;
80
+ }
81
+ catch (_unreadableResponse) {
82
+ capturedResponse = undefined;
83
+ }
84
+ }
85
+ const snapshot = httpFailure
86
+ ? snapshotHttpFailureFields(failure, capturedResponse)
87
+ : snapshotCredentials(failure);
88
+ const data = (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) ? snapshot : {});
89
+ const message = typeof data.message === 'string'
90
+ ? data.message
91
+ : typeof snapshot === 'string'
92
+ ? snapshot
93
+ : 'Authentication failed';
94
+ let error;
95
+ if (failure instanceof I18nError && typeof data.i18nKey === 'string') {
96
+ error = new I18nError(data.i18nKey, data.i18nValues, message);
97
+ }
98
+ else if (httpFailure) {
99
+ error = new HttpError(message, typeof data.status === 'number' ? data.status : 0, capturedResponse ?? data.response, data.config, typeof data.errorKey === 'string' ? data.errorKey : undefined, data.aborted === true);
100
+ }
101
+ else if (failure instanceof AggregateError) {
102
+ error = new AggregateError(Array.isArray(data.errors) ? data.errors : [], message);
103
+ }
104
+ else if (failure instanceof TypeError) {
105
+ error = new TypeError(message);
106
+ }
107
+ else {
108
+ error = new Error(message);
109
+ }
110
+ copyFailureSnapshot(error, data, error instanceof HttpError);
111
+ if (httpFailure) {
112
+ Object.defineProperty(error, 'response', {
113
+ value: capturedResponse ?? data.response,
114
+ enumerable: true,
115
+ configurable: true,
116
+ writable: true,
117
+ });
118
+ }
119
+ return error;
120
+ };
121
+ /** Give every auth operation the same detached, fresh-failure boundary. */
122
+ const runAuthOperation = async (operation) => {
123
+ try {
124
+ return await operation();
125
+ }
126
+ catch (error) {
127
+ throw await snapshotAuthFailure(error);
128
+ }
129
+ };
12
130
  /**
13
131
  * Whether this app is running inside a CROSS-SITE iframe — i.e. the molecule.dev
14
132
  * live preview (the app at `127.0.0.1:<port>` embedded in the `localhost:3000`
@@ -164,18 +282,17 @@ const applyProfileAnswer = (profile, answer) => {
164
282
  * fields (see {@link AuthProfileConstraint}), including a profile with no `id`.
165
283
  * The default, `UserProfile`, requires `id: string`.
166
284
  * @template TRegister - The app's own registration fields, accepted at the top
167
- * level of `register()` (e.g. `{ username: string }` for a RealWorld-shaped API).
285
+ * level of `register()` (e.g. `{ username: string }` for an account API).
168
286
  * An interface and a type alias are equally valid.
169
287
  * @param config - Auth client configuration including endpoints, storage, and refresh settings.
170
288
  * @returns A fully configured `AuthClient` instance.
171
289
  */
172
290
  export const createJWTAuthClient = (config = {}) => {
173
- const { baseURL = '', loginEndpoint = '/auth/login', logoutEndpoint = '/users/logout', registerEndpoint = '/auth/register', refreshEndpoint = '/auth/refresh', profileEndpoint = '/auth/profile', profileUpdateMethod = 'PATCH', profileRequestBody, forgotPasswordEndpoint = '/auth/password/reset', resetPasswordEndpoint = '/auth/password/reset/confirm', changePasswordEndpoint = '/auth/password/change', currentUserEndpoint = '/users/me', storagePrefix = 'molecule:auth:', storage: configuredStorage, shouldRestoreUser, autoRefresh = true, refreshBuffer = 60, } = config;
291
+ const { baseURL = '', loginEndpoint = '/auth/login', logoutEndpoint = '/users/logout', registerEndpoint = '/auth/register', refreshEndpoint = '/auth/refresh', profileEndpoint = '/auth/profile', profileUpdateMethod = 'PATCH', profileRequestBody, forgotPasswordEndpoint = '/auth/password/reset', resetPasswordEndpoint = '/auth/password/reset/confirm', changePasswordEndpoint = '/auth/password/change', changePasswordMethod = 'POST', currentUserEndpoint = '/users/me', cookieRestore = 'hint', storagePrefix = 'molecule:auth:', storage: configuredStorage, shouldRestoreUser, autoRefresh = true, refreshBuffer = 60, } = config;
174
292
  const logger = getLogger('auth');
175
293
  // In a browser the default is Web Storage: a session kept in memory is lost on
176
294
  // every full page load, so a `page.goto` after sign-in signs the user out
177
- // (xr-molecule-flash-m158: 40 of 65 local failures were this one default). SSR
178
- // and Node tests have no Web Storage, so they keep `'memory'`.
295
+ // SSR and Node tests have no Web Storage, so they keep `'memory'`.
179
296
  const storage = configuredStorage ?? (hasBrowserStorage() ? createBrowserStorageAdapter() : 'memory');
180
297
  // `tokenKey` exists so something OUTSIDE this client reads the token at its own
181
298
  // key. An explicit in-memory store makes that impossible: nothing else can see
@@ -184,10 +301,25 @@ export const createJWTAuthClient = (config = {}) => {
184
301
  logger.warn(`tokenKey '${config.tokenKey}' is in memory — nothing outside this client can read it and a reload signs the user out; pass storage: createBrowserStorageAdapter()`);
185
302
  }
186
303
  const tokenStorage = createTokenStorage(storage, storagePrefix, config.tokenKey);
304
+ const signOutMarkerKey = `${storagePrefix}signedOut`;
305
+ let memorySignOutMarker = null;
306
+ const getSignOutMarker = () => typeof storage === 'object' ? storage.getItem(signOutMarkerKey) : memorySignOutMarker;
307
+ const setSignOutMarker = () => {
308
+ if (typeof storage === 'object')
309
+ storage.setItem(signOutMarkerKey, '1');
310
+ else
311
+ memorySignOutMarker = '1';
312
+ };
313
+ const clearSignOutMarker = () => {
314
+ if (typeof storage === 'object')
315
+ storage.removeItem(signOutMarkerKey);
316
+ else
317
+ memorySignOutMarker = null;
318
+ };
187
319
  const stateListeners = new Set();
188
320
  const eventListeners = new Set();
189
321
  let refreshTimer = null;
190
- // [m95] The session restore `initialize()` started, or null when none is running.
322
+ // The session restore `initialize()` started, or null when none is running.
191
323
  // Assigned SYNCHRONOUSLY, before that call's first await, so a component effect
192
324
  // running in the same tick (bootstrapApp calls initialize() and then renders)
193
325
  // already sees the restore and its HTTP requests wait for it (`whenRestored`).
@@ -196,6 +328,30 @@ export const createJWTAuthClient = (config = {}) => {
196
328
  // sign-in/sign-out invalidates older profile writes, even if token strings
197
329
  // happen to match. Background profile reads do not replace the session.
198
330
  let sessionRevision = 0;
331
+ // Accepted profile writes also own the cached profile: a read captured before
332
+ // one must not restore the older record after the save has been published.
333
+ let profileRevision = 0;
334
+ let pendingSessionOperations = 0;
335
+ let lastSignOutRevision = -1;
336
+ // A cookie can authenticate without a script-readable bearer or presence hint.
337
+ // Keep its candidacy until a session decision, independently of token storage.
338
+ let cookieSessionCandidate = false;
339
+ /**
340
+ * Runs every asynchronous session operation under the epoch that started it.
341
+ * The pending count makes an otherwise settled-looking anonymous client a
342
+ * session candidate for `setUser(null)`; the guard owns every later effect.
343
+ */
344
+ const runSessionOperation = async (callback) => {
345
+ const revision = sessionRevision;
346
+ const operation = { revision, isCurrent: () => revision === sessionRevision };
347
+ pendingSessionOperations++;
348
+ try {
349
+ return await runAuthOperation(() => callback(operation));
350
+ }
351
+ finally {
352
+ pendingSessionOperations--;
353
+ }
354
+ };
199
355
  let state = {
200
356
  status: 'loading',
201
357
  initialized: false,
@@ -239,7 +395,7 @@ export const createJWTAuthClient = (config = {}) => {
239
395
  // `authenticated` changing is that decision (an explicit status short-circuits
240
396
  // above for every other one).
241
397
  if (state.status === 'unavailable' &&
242
- tokenStorage.getAccessToken() &&
398
+ (tokenStorage.getAccessToken() || cookieSessionCandidate) &&
243
399
  next.authenticated === state.authenticated) {
244
400
  return 'unavailable';
245
401
  }
@@ -247,17 +403,16 @@ export const createJWTAuthClient = (config = {}) => {
247
403
  return 'authenticated';
248
404
  return 'unauthenticated';
249
405
  };
250
- const setState = (updates) => {
406
+ const setState = (updates, revisionAdvanced = false) => {
251
407
  const previous = state;
252
408
  const next = { ...previous, ...updates };
253
- if (previous.authenticated && !next.authenticated)
409
+ if (previous.authenticated && !next.authenticated && !revisionAdvanced)
254
410
  sessionRevision++;
255
411
  // A user REFERENCE changes only when its data changes. A background verification
256
412
  // (`validateSession`, a reload's cookie restore) answers with a freshly parsed object that
257
413
  // usually carries exactly the data already in state; handing that new reference to every
258
414
  // subscriber re-runs every effect keyed on `user`, and a form seeded from it resets whatever
259
- // the user has typed since (xr-molecule-flash-m173: a Settings form wiped the test's bio
260
- // between `fill` and submit, so the PUT sent `"bio": ""`).
415
+ // the user has typed since, replacing unsaved form input with cached data.
261
416
  if (updates.user !== undefined && sameUserData(updates.user, previous.user)) {
262
417
  next.user = previous.user;
263
418
  }
@@ -274,13 +429,33 @@ export const createJWTAuthClient = (config = {}) => {
274
429
  if (!unchanged)
275
430
  notify();
276
431
  };
432
+ /** Ends local ownership before any cleanup, so older operations cannot publish. */
433
+ const endLocalSession = () => {
434
+ sessionRevision++;
435
+ lastSignOutRevision = sessionRevision;
436
+ setSignOutMarker();
437
+ if (refreshTimer) {
438
+ clearTimeout(refreshTimer);
439
+ refreshTimer = null;
440
+ }
441
+ tokenStorage.clear();
442
+ cookieSessionCandidate = false;
443
+ clearSessionHintCookie();
444
+ setState({
445
+ initialized: true,
446
+ loading: false,
447
+ authenticated: false,
448
+ user: null,
449
+ status: 'unauthenticated',
450
+ }, true);
451
+ };
277
452
  /**
278
453
  * Whether `err` is the client's own "nothing to refresh with" error thrown by
279
454
  * `refresh()` when no refresh token is stored (and the cookie-restore fallback
280
455
  * found no session).
281
456
  */
282
457
  const isNoRefreshTokenError = (err) => err instanceof I18nError && err.i18nKey === 'auth.error.noRefreshToken';
283
- const scheduleRefresh = () => {
458
+ const scheduleRefresh = (retryAfterTransient = false) => {
284
459
  if (!autoRefresh)
285
460
  return;
286
461
  const token = tokenStorage.getAccessToken();
@@ -291,7 +466,7 @@ export const createJWTAuthClient = (config = {}) => {
291
466
  // keep the durable session in an httpOnly cookie the SERVER refreshes. A
292
467
  // timer here can only fail with `auth.error.noRefreshToken` — and it used to
293
468
  // LOG THE USER OUT on that failure while their access token was still valid
294
- // (a RealWorld-shaped app signed itself out right after register). Leave the
469
+ // (an app without refresh tokens signed itself out right after register). Leave the
295
470
  // session alone; the API's next 401 (see `validateSession`) or an explicit
296
471
  // sign-out decides when it ends.
297
472
  if (!tokenStorage.getRefreshToken())
@@ -299,33 +474,43 @@ export const createJWTAuthClient = (config = {}) => {
299
474
  const expiresAt = getTokenExpiration(token);
300
475
  if (!expiresAt)
301
476
  return;
302
- const refreshTime = expiresAt - refreshBuffer * 1000 - Date.now();
477
+ // A transient failure must not consume the one timer permanently: the
478
+ // original expiry-based instant has already passed, so retry on a bounded
479
+ // delay while retaining the session as `unavailable`. A success replaces
480
+ // this retry with the new token's normal expiry-based schedule.
481
+ const refreshTime = retryAfterTransient ? 30_000 : expiresAt - refreshBuffer * 1000 - Date.now();
303
482
  if (refreshTime <= 0)
304
483
  return;
305
484
  if (refreshTimer) {
306
485
  clearTimeout(refreshTimer);
307
486
  }
308
487
  refreshTimer = setTimeout(() => {
309
- client.refresh().catch((err) => {
310
- // The refresh token can disappear between scheduling and firing (a
311
- // logout in another tab, a cleared store). That is not a failed refresh,
312
- // so it must not sign the user out: keep the session and let the API
313
- // reject the token if it really is dead.
314
- if (isNoRefreshTokenError(err)) {
315
- logger.debug('Token refresh skipped — no refresh token stored; keeping the session until the API rejects the token', err);
316
- return;
488
+ void runSessionOperation(async (operation) => {
489
+ try {
490
+ await client.refresh();
317
491
  }
318
- // ONLY the server rejecting the refresh token (401/403) ends the session.
319
- // A network drop, an aborted request, or a 5xx says nothing about the
320
- // credential — signing out here did exactly that on every flaky refresh.
321
- // The API's next 401 (`validateSession`) or an explicit sign-out decides.
322
- const httpStatus = err.status;
323
- if (httpStatus === 401 || httpStatus === 403) {
324
- logger.warn('Token refresh was rejected, signing out', err);
325
- void client.logout();
326
- return;
492
+ catch (err) {
493
+ // The refresh token can disappear between scheduling and firing (a
494
+ // logout in another tab, a cleared store). That is not a failed refresh,
495
+ // so it must not sign the user out: keep the session and let the API
496
+ // reject the token if it really is dead.
497
+ if (isNoRefreshTokenError(err)) {
498
+ logger.debug('Token refresh skipped — no refresh token stored; keeping the session until the API rejects the token', err);
499
+ return;
500
+ }
501
+ // ONLY a current server rejection ends the session. An old timer's
502
+ // 401 must not destroy a login that replaced it while it was pending.
503
+ if (!operation.isCurrent())
504
+ return;
505
+ const httpStatus = err.status;
506
+ if ((httpStatus === 401 || httpStatus === 403) && operation.isCurrent()) {
507
+ logger.warn('Token refresh was rejected, signing out', err);
508
+ await client.logout();
509
+ return;
510
+ }
511
+ logger.warn('Token refresh failed; keeping the session and scheduling a retry', err);
512
+ scheduleRefresh(true);
327
513
  }
328
- logger.warn('Token refresh failed; keeping the session until the API rejects the token', err);
329
514
  });
330
515
  }, refreshTime);
331
516
  };
@@ -336,8 +521,7 @@ export const createJWTAuthClient = (config = {}) => {
336
521
  // With `AuthClientConfig<T>` the mapper's answer is already `AuthResult<T>`:
337
522
  // the compiler checks its user against the profile this client (and the
338
523
  // hook) hands to pages, so no cast may re-label it as `T` after mapping
339
- // (xr-molecule-flash-m255: the cast let a mapper drop required fields the
340
- // navbar read).
524
+ // while keeping the custom fields that consumers read.
341
525
  if (config.mapAuthResponse) {
342
526
  const mapped = config.mapAuthResponse(data);
343
527
  try {
@@ -392,7 +576,7 @@ export const createJWTAuthClient = (config = {}) => {
392
576
  return fallback;
393
577
  }
394
578
  };
395
- const fetchAPI = async (endpoint, options = {}) => {
579
+ const fetchAPI = async (endpoint, options = {}) => runAuthOperation(async () => {
396
580
  const url = baseURL + endpoint;
397
581
  const headers = {
398
582
  'Content-Type': 'application/json',
@@ -414,13 +598,13 @@ export const createJWTAuthClient = (config = {}) => {
414
598
  headers,
415
599
  });
416
600
  if (!response.ok) {
417
- // The parsed answer, kept WHOLE: the client maps `errorKey`/`error`/
418
- // `message` into the I18nError it throws, but every other field — a
419
- // RealWorld `{ errors: { credentials: ['is invalid'] } }` map, field
420
- // arrays, a top-level array — is preserved on `body` so the app can show
421
- // the server's own messages. A non-JSON body
422
- // (HTML error page, empty) parses to `{}`, exactly as before.
423
- const body = await response.json().catch(() => ({}));
601
+ // The parsed answer keeps its complete shape: the client maps `errorKey`/`error`/
602
+ // `message` into the I18nError it throws, and every other field — a structured
603
+ // `{ errors: { credentials: ['is invalid'] } }` map, field arrays, a top-level array —
604
+ // remains on `body` so the app can show the server's own messages. Credential values
605
+ // are redacted in place; non-secret values are unchanged. A non-JSON body (HTML error
606
+ // page, empty) parses to `{}`, exactly as before.
607
+ const body = redactCredentials(await response.json().catch(() => ({})));
424
608
  const fields = (body ?? {});
425
609
  // JSON is untyped: structured validation errors belong on `body`, never
426
610
  // in the translation key or its fallback text.
@@ -435,8 +619,31 @@ export const createJWTAuthClient = (config = {}) => {
435
619
  // from "the server did not answer" (5xx, network) or "the route does not
436
620
  // exist" (404) — `validateSession` clears the session only on the first.
437
621
  failure.status = response.status;
438
- // The response body, exactly as parsed — see {@link AuthError}.
622
+ // The response body with credential values redacted — see {@link AuthError}.
439
623
  failure.body = body;
624
+ // Mirror app-http's failed-response view without importing it. Keep the
625
+ // original auth fields above intact: both documented readings now work on
626
+ // the SAME rejection, including across duplicated package copies.
627
+ const headers = {};
628
+ response.headers.forEach((value, name) => {
629
+ headers[name] = value;
630
+ });
631
+ failure.response = {
632
+ data: body,
633
+ status: response.status,
634
+ statusText: response.statusText,
635
+ headers: redactCredentials({ headers }).headers,
636
+ // RequestInit bodies contain passwords and refresh tokens, and its
637
+ // headers contain bearer/cookie credentials. Method and a credential-
638
+ // redacted URL are the complete request view an auth rejection may expose.
639
+ config: {
640
+ method: (options.method ?? 'GET').toUpperCase(),
641
+ url: redactCredentials(url),
642
+ },
643
+ };
644
+ failure.errorKey = key;
645
+ failure.aborted = false;
646
+ Object.defineProperty(failure, HTTP_ERROR_BRAND, { value: true });
440
647
  throw failure;
441
648
  }
442
649
  const data = await response.json();
@@ -448,29 +655,72 @@ export const createJWTAuthClient = (config = {}) => {
448
655
  data.user = data.props;
449
656
  }
450
657
  return data;
658
+ });
659
+ /**
660
+ * A stale credential exchange can set an HttpOnly cookie even when its local
661
+ * result is ignored. If its sign-out still owns this client, revoke those late
662
+ * credentials without delaying the caller. Never revoke a replacement session.
663
+ */
664
+ const discardLateSession = (operation, result) => {
665
+ if (operation.isCurrent() ||
666
+ state.authenticated ||
667
+ lastSignOutRevision <= operation.revision ||
668
+ !getSignOutMarker() ||
669
+ result.twoFactorRequired) {
670
+ return;
671
+ }
672
+ clearSessionHintCookie();
673
+ if (!logoutEndpoint)
674
+ return;
675
+ const accessToken = result.accessToken;
676
+ void runSessionOperation(async (cleanup) => {
677
+ try {
678
+ await fetchAPI(logoutEndpoint, {
679
+ method: 'POST',
680
+ credentials: 'include',
681
+ headers: accessToken
682
+ ? { Authorization: `${config.authScheme ?? 'Bearer'} ${accessToken}` }
683
+ : {},
684
+ });
685
+ }
686
+ catch (error) {
687
+ logger.debug('Late session revocation failed (non-critical)', error);
688
+ }
689
+ finally {
690
+ if (cleanup.isCurrent() && !state.authenticated && getSignOutMarker()) {
691
+ clearSessionHintCookie();
692
+ }
693
+ }
694
+ });
451
695
  };
452
- // [M1-1] Session restore from the httpOnly cookie. The bearer token is held
696
+ // Session restore from the httpOnly cookie. The bearer token is held
453
697
  // in memory (a localStorage copy is XSS-exfiltratable, so it is forbidden)
454
698
  // and is therefore lost on a full page load — but the httpOnly session cookie
455
699
  // persists. Probe the current-user endpoint with credentials; if the cookie
456
700
  // is still valid the server returns the user, and we re-establish the
457
701
  // authenticated session WITHOUT the token ever being exposed to JavaScript.
458
702
  //
459
- // GATE on the non-httpOnly `mol_auth` presence hint (set by the server
460
- // alongside the httpOnly token): only probe when a session plausibly exists.
461
- // Without this gate, every anonymous caller would fire a guaranteed-401
462
- // request (extra API call + console error). A forged hint only costs one
463
- // harmless 401.
703
+ // By default, use the readable `mol_auth=1` hint to avoid probing every anonymous
704
+ // load. External cookie APIs can opt into `cookieRestore: 'always'`. Once this
705
+ // client has a candidate, retries do not depend on the hint remaining readable.
464
706
  //
465
707
  // Shared by initialize() (reload/deep-link restore) and refresh() (see the
466
708
  // no-refresh-token fallback there).
467
- const restoreFromSessionCookie = async () => {
709
+ const restoreFromSessionCookie = async () => runSessionOperation(async (operation) => {
710
+ if (getSignOutMarker()) {
711
+ clearSessionHintCookie();
712
+ return { user: null, attempted: false, rejected: false };
713
+ }
468
714
  const hasSessionHint = typeof document !== 'undefined' && /(?:^|;\s*)mol_auth=1(?:;|$)/.test(document.cookie);
469
- if (!hasSessionHint)
715
+ if (!hasSessionHint && !cookieSessionCandidate && cookieRestore !== 'always') {
470
716
  return { user: null, attempted: false, rejected: false };
471
- const revision = sessionRevision;
717
+ }
718
+ cookieSessionCandidate = true;
472
719
  const token = tokenStorage.getAccessToken();
473
- const isCurrent = () => revision === sessionRevision && token === tokenStorage.getAccessToken();
720
+ const readProfileRevision = profileRevision;
721
+ const isCurrent = () => operation.isCurrent() &&
722
+ token === tokenStorage.getAccessToken() &&
723
+ readProfileRevision === profileRevision;
474
724
  try {
475
725
  const result = await fetchAPI(currentUserEndpoint, {
476
726
  method: 'GET',
@@ -479,37 +729,54 @@ export const createJWTAuthClient = (config = {}) => {
479
729
  // A sign-out or account switch while this GET was pending owns the new
480
730
  // state. Neither an old profile nor an old rejection may replace it.
481
731
  if (!isCurrent())
482
- return { user: null, attempted: false, rejected: false };
732
+ return { user: null, attempted: false, rejected: false, stale: true };
483
733
  const rawUser = (result?.user ?? null);
484
734
  // The same adapter a sign-in's answer goes through — this answer is the
485
735
  // wire shape too, and storing it raw dropped the adapted identity.
486
736
  const restoredUser = mapCurrentUser(result, rawUser);
487
- if (restoredUser && shouldRestoreUser && !shouldRestoreUser(restoredUser)) {
488
- // The server DOES have a session (e.g. an anonymous/guest one), but the
489
- // app says it must not count as a signed-in client. Stay logged out and
490
- // drop the presence hint so later loads skip the probe entirely; the
491
- // httpOnly session cookie is untouched and keeps authenticating API
492
- // calls (a guest's projects still work).
493
- clearSessionHintCookie();
494
- return { user: null, attempted: true, rejected: false };
737
+ // The mapper and restore predicate are caller code and can end or replace
738
+ // the session synchronously. Re-check ownership after both, before any write.
739
+ if (!isCurrent())
740
+ return { user: null, attempted: false, rejected: false, stale: true };
741
+ if (restoredUser && shouldRestoreUser) {
742
+ const restore = shouldRestoreUser(restoredUser);
743
+ if (!isCurrent())
744
+ return { user: null, attempted: false, rejected: false, stale: true };
745
+ if (!restore) {
746
+ // The server DOES have a session (e.g. an anonymous/guest one), but the
747
+ // app says it must not count as a signed-in client. Stay logged out and
748
+ // drop the presence hint so later loads skip the probe entirely; the
749
+ // httpOnly session cookie is untouched and keeps authenticating API
750
+ // calls (a guest's projects still work).
751
+ clearSessionHintCookie();
752
+ cookieSessionCandidate = false;
753
+ return { user: null, attempted: true, rejected: false, declined: true };
754
+ }
495
755
  }
496
756
  if (restoredUser) {
757
+ clearSignOutMarker();
497
758
  tokenStorage.setUser(restoredUser);
498
- setState({ status: 'authenticated', authenticated: true, user: restoredUser });
759
+ setState({
760
+ status: 'authenticated',
761
+ authenticated: true,
762
+ user: restoredUser,
763
+ error: null,
764
+ });
499
765
  return { user: restoredUser, attempted: true, rejected: false };
500
766
  }
501
767
  return { user: null, attempted: true, rejected: false };
502
768
  }
503
769
  catch (error) {
504
770
  if (!isCurrent())
505
- return { user: null, attempted: false, rejected: false };
771
+ return { user: null, attempted: false, rejected: false, stale: true };
506
772
  const httpStatus = error.status;
507
773
  // Only a REJECTED cookie (401/403) proves the hint stale: the session
508
774
  // expired server-side, or the database was reset. Drop the hint then, so
509
775
  // later loads skip a guaranteed-401 probe; the next login re-sets it.
510
776
  if (httpStatus === 401 || httpStatus === 403) {
511
777
  clearSessionHintCookie();
512
- return { user: null, attempted: true, rejected: true };
778
+ cookieSessionCandidate = false;
779
+ return { user: null, attempted: true, rejected: true, failure: error };
513
780
  }
514
781
  // Every other failure says nothing about the cookie. A 404 means the
515
782
  // configured `currentUserEndpoint` does not exist (a config error the app
@@ -526,6 +793,7 @@ export const createJWTAuthClient = (config = {}) => {
526
793
  user: null,
527
794
  attempted: true,
528
795
  rejected: false,
796
+ failure: error,
529
797
  error: httpStatus === 404
530
798
  ? 'The current-user endpoint was not found. Serve the route or set currentUserEndpoint to it; the stored session was kept.'
531
799
  : error instanceof Error
@@ -533,12 +801,15 @@ export const createJWTAuthClient = (config = {}) => {
533
801
  : 'The session could not be confirmed',
534
802
  };
535
803
  }
536
- };
804
+ });
537
805
  // The body of `initialize()`, kept separate so the client can record the in-flight
538
806
  // promise SYNCHRONOUSLY (see `restoreInFlight`): a caller that awaits `initialize()`
539
807
  // still sees a rejection, while `whenRestored()` hands out a promise that resolves.
540
- const initializeSession = async () => {
541
- const revision = sessionRevision;
808
+ const initializeSession = async () => runSessionOperation(async (operation) => {
809
+ if (getSignOutMarker()) {
810
+ endLocalSession();
811
+ return;
812
+ }
542
813
  const token = tokenStorage.getAccessToken();
543
814
  const user = tokenStorage.getUser();
544
815
  if (token && user) {
@@ -548,8 +819,7 @@ export const createJWTAuthClient = (config = {}) => {
548
819
  // verification below settles BEHIND that render; only an explicit 401/403
549
820
  // ends the session (the same classification `validateSession` uses). A page
550
821
  // that waited on its own `/me` call before painting paid a full API round
551
- // trip on every navigation (xr-molecule-flash-m177: ~30 page loads in one
552
- // pagination spec, its four tests at 15.0–15.9 s of a 15 s budget).
822
+ // trip on every navigation.
553
823
  setState({
554
824
  initialized: true,
555
825
  authenticated: true,
@@ -566,41 +836,34 @@ export const createJWTAuthClient = (config = {}) => {
566
836
  if (refreshToken) {
567
837
  try {
568
838
  await client.refresh();
569
- if (revision !== sessionRevision)
839
+ if (!operation.isCurrent())
570
840
  return;
571
841
  setState({ initialized: true });
572
842
  return;
573
843
  }
574
844
  catch (error) {
575
- if (revision !== sessionRevision)
845
+ if (!operation.isCurrent())
576
846
  return;
577
847
  const httpStatus = error.status;
578
848
  // Only the server REJECTING the refresh token proves the session dead.
579
849
  // A network drop or a 5xx is not about the credential, and it used to
580
- // sign the user out here (the m159 class).
850
+ // sign the user out here.
581
851
  if (httpStatus === 401 || httpStatus === 403) {
582
852
  logger.warn('Initial token refresh was rejected; signing out', error);
583
- tokenStorage.clear();
584
- setState({
585
- initialized: true,
586
- authenticated: false,
587
- user: null,
588
- status: 'unauthenticated',
589
- });
853
+ endLocalSession();
590
854
  return;
591
855
  }
592
856
  logger.warn('Initial token refresh failed; keeping the stored session until the API rejects it', error);
593
857
  }
594
858
  }
595
- // [M1-1] Cookie-restore — this is what keeps a refresh / deep-link logged
859
+ // Cookie-restore — this is what keeps a refresh / deep-link logged
596
860
  // in under the in-memory-token model. See restoreFromSessionCookie above.
597
861
  const outcome = await restoreFromSessionCookie();
598
- if (revision !== sessionRevision)
862
+ if (!operation.isCurrent())
599
863
  return;
600
- if (outcome.rejected) {
864
+ if (outcome.rejected || outcome.declined) {
601
865
  // The only other proof of this session was rejected by the server.
602
- tokenStorage.clear();
603
- setState({ initialized: true, authenticated: false, user: null, status: 'unauthenticated' });
866
+ endLocalSession();
604
867
  return;
605
868
  }
606
869
  if (outcome.attempted && !outcome.user) {
@@ -623,16 +886,31 @@ export const createJWTAuthClient = (config = {}) => {
623
886
  // Reuse its mapping, failure classification and session ownership guards.
624
887
  if (token) {
625
888
  await client.validateSession();
626
- if (revision !== sessionRevision)
889
+ if (!operation.isCurrent())
627
890
  return;
628
891
  setState({ initialized: true });
629
892
  return;
630
893
  }
631
- await restoreFromSessionCookie();
632
- if (revision !== sessionRevision)
894
+ const outcome = await restoreFromSessionCookie();
895
+ if (!operation.isCurrent())
896
+ return;
897
+ if (outcome.rejected || outcome.declined) {
898
+ endLocalSession();
633
899
  return;
900
+ }
901
+ if (outcome.error !== undefined) {
902
+ // A cookie-only candidate is still a session candidate. A transient
903
+ // failure (including a missing current-user route) leaves it available
904
+ // for retry; only 401/403 may classify it as signed out.
905
+ setState({
906
+ initialized: true,
907
+ status: 'unavailable',
908
+ error: outcome.error,
909
+ });
910
+ return;
911
+ }
634
912
  setState({ initialized: true });
635
- };
913
+ });
636
914
  /**
637
915
  * Starts the client's ONE session restore, or returns the one already running.
638
916
  *
@@ -690,6 +968,16 @@ export const createJWTAuthClient = (config = {}) => {
690
968
  },
691
969
  getSessionRevision: () => sessionRevision,
692
970
  setUser: (user) => {
971
+ if (user === null &&
972
+ (state.authenticated ||
973
+ cookieSessionCandidate ||
974
+ pendingSessionOperations > 0 ||
975
+ state.loading ||
976
+ !state.initialized)) {
977
+ // Settled-looking state can still have a refresh retry in flight.
978
+ endLocalSession();
979
+ return;
980
+ }
693
981
  if (user === null)
694
982
  sessionRevision++;
695
983
  tokenStorage.setUser(user);
@@ -703,6 +991,8 @@ export const createJWTAuthClient = (config = {}) => {
703
991
  },
704
992
  setSession: (session) => {
705
993
  sessionRevision++;
994
+ clearSignOutMarker();
995
+ cookieSessionCandidate = false;
706
996
  // NOT passed through `mapAuthResponse`: a seed is not an API answer, it is
707
997
  // the profile the caller already built in this client's shape. Mapping it
708
998
  // would adapt an adapted profile a second time.
@@ -714,8 +1004,7 @@ export const createJWTAuthClient = (config = {}) => {
714
1004
  // The COMPLETE state, in one update and one notification: an out-of-band exchange has
715
1005
  // already proved the session, so `authenticated` must flip with the token. Writing the
716
1006
  // token and the user with the two field setters leaves the client signed out for every
717
- // shared hook (xr-molecule-flash-m178: nine navbar/profile assertions read
718
- // `state.authenticated` and timed out after a successful registration).
1007
+ // shared hook, even after a successful registration.
719
1008
  setState({
720
1009
  initialized: true,
721
1010
  authenticated: true,
@@ -728,222 +1017,296 @@ export const createJWTAuthClient = (config = {}) => {
728
1017
  },
729
1018
  getRefreshToken: () => tokenStorage.getRefreshToken(),
730
1019
  async login(credentials) {
731
- setState({ loading: true, error: null });
732
- try {
733
- const result = mapResult(await fetchAPI(loginEndpoint, {
734
- method: 'POST',
735
- body: JSON.stringify(shapeBody('login', credentials)),
736
- }));
737
- // Two-factor gate: the server answered 206 `{ twoFactorRequired: true }`
738
- // — no tokens/user issued. Surface the result WITHOUT marking the
739
- // session authenticated; the caller re-prompts for the TOTP and retries.
740
- if (result.twoFactorRequired) {
741
- setState({ loading: false });
1020
+ return runSessionOperation(async (operation) => {
1021
+ setState({ loading: true, error: null });
1022
+ try {
1023
+ const result = mapResult(await fetchAPI(loginEndpoint, {
1024
+ method: 'POST',
1025
+ body: JSON.stringify(shapeBody('login', credentials)),
1026
+ }));
1027
+ // Mapping is caller code too: check after it, immediately before any
1028
+ // state, credential, event, or timer publication.
1029
+ if (!operation.isCurrent()) {
1030
+ discardLateSession(operation, result);
1031
+ return result;
1032
+ }
1033
+ // Two-factor gate: the server answered 206 `{ twoFactorRequired: true }`
1034
+ // — no tokens/user issued. Surface the result WITHOUT marking the
1035
+ // session authenticated; the caller re-prompts for the TOTP and retries.
1036
+ if (result.twoFactorRequired) {
1037
+ setState({ loading: false });
1038
+ return result;
1039
+ }
1040
+ sessionRevision++;
1041
+ clearSignOutMarker();
1042
+ cookieSessionCandidate = !result.accessToken;
1043
+ tokenStorage.setAccessToken(result.accessToken || null);
1044
+ tokenStorage.setRefreshToken(result.refreshToken || null);
1045
+ tokenStorage.setUser(result.user);
1046
+ setState({
1047
+ status: 'authenticated',
1048
+ loading: false,
1049
+ authenticated: true,
1050
+ user: result.user,
1051
+ });
1052
+ logger.debug('User logged in');
1053
+ emitEvent({ type: 'login', user: result.user });
1054
+ scheduleRefresh();
742
1055
  return result;
743
1056
  }
744
- sessionRevision++;
745
- tokenStorage.setAccessToken(result.accessToken || null);
746
- tokenStorage.setRefreshToken(result.refreshToken || null);
747
- tokenStorage.setUser(result.user);
748
- setState({
749
- status: 'authenticated',
750
- loading: false,
751
- authenticated: true,
752
- user: result.user,
753
- });
754
- logger.debug('User logged in');
755
- emitEvent({ type: 'login', user: result.user });
756
- scheduleRefresh();
757
- return result;
758
- }
759
- catch (err) {
760
- const error = err instanceof Error ? err.message : 'Login failed';
761
- setState({ loading: false, error });
762
- emitEvent({ type: 'error', error });
763
- throw err;
764
- }
1057
+ catch (err) {
1058
+ const failure = await snapshotAuthFailure(err);
1059
+ if (!operation.isCurrent())
1060
+ throw failure;
1061
+ setState({ loading: false, error: failure.message });
1062
+ emitEvent({ type: 'error', error: failure.message });
1063
+ throw failure;
1064
+ }
1065
+ });
765
1066
  },
766
1067
  async logout() {
767
- sessionRevision++;
768
- setState({ loading: true });
769
- try {
770
- // [M1-1] The persistent credential is the httpOnly session cookie, not
771
- // the in-memory bearer token. The bearer does not survive a page reload
772
- // (after a reload `initialize()` restores `authenticated` from the cookie
773
- // but cannot repopulate the in-memory token), so guarding the server
774
- // logout on `token` alone meant a reloaded session could never log out
775
- // server-side and the httpOnly cookie lived on. Call the endpoint
776
- // whenever we believe a session exists — token OR restored-authenticated —
777
- // and send `credentials` so the cookie authenticates the device
778
- // revocation and the server clears the httpOnly cookie.
1068
+ await runSessionOperation(async (operation) => {
1069
+ if (!operation.isCurrent())
1070
+ return;
1071
+ // Capture revocation authority before local cleanup removes it. The
1072
+ // cookie remains in the request; a bearer-only API gets the old token.
779
1073
  const token = tokenStorage.getAccessToken();
780
- if (logoutEndpoint && (token || state.authenticated)) {
781
- await fetchAPI(logoutEndpoint, { method: 'POST', credentials: 'include' }).catch((err) => {
782
- logger.debug('Logout endpoint call failed (non-critical)', err);
783
- });
784
- }
785
- }
786
- finally {
787
- if (refreshTimer) {
788
- clearTimeout(refreshTimer);
789
- refreshTimer = null;
790
- }
791
- tokenStorage.clear();
792
- // [M1-1] Drop the non-httpOnly session-presence hint so initialize()
793
- // won't probe for cookie-restore on the next load even if the server
794
- // logout call didn't complete (the server clears it too, on success).
795
- clearSessionHintCookie();
796
- setState({
797
- initialized: true,
798
- loading: false,
799
- authenticated: false,
800
- user: null,
801
- // An explicit sign-out settles the question, whatever came before.
802
- status: 'unauthenticated',
803
- });
1074
+ const revoke = logoutEndpoint && (token || state.authenticated || cookieSessionCandidate);
1075
+ endLocalSession();
804
1076
  logger.debug('User logged out');
805
1077
  emitEvent({ type: 'logout' });
806
- }
1078
+ if (revoke && logoutEndpoint) {
1079
+ await fetchAPI(logoutEndpoint, {
1080
+ method: 'POST',
1081
+ credentials: 'include',
1082
+ headers: token ? { Authorization: `${config.authScheme ?? 'Bearer'} ${token}` } : {},
1083
+ }).catch((error) => {
1084
+ logger.debug('Logout endpoint call failed (non-critical)', error);
1085
+ });
1086
+ }
1087
+ });
807
1088
  },
808
1089
  async register(data) {
809
- setState({ loading: true, error: null });
810
- try {
811
- const result = mapResult(await fetchAPI(registerEndpoint, {
812
- method: 'POST',
813
- body: JSON.stringify(shapeBody('register', data)),
814
- }));
815
- sessionRevision++;
1090
+ return runSessionOperation(async (operation) => {
1091
+ setState({ loading: true, error: null });
1092
+ try {
1093
+ const result = mapResult(await fetchAPI(registerEndpoint, {
1094
+ method: 'POST',
1095
+ body: JSON.stringify(shapeBody('register', data)),
1096
+ }));
1097
+ if (!operation.isCurrent()) {
1098
+ discardLateSession(operation, result);
1099
+ return result;
1100
+ }
1101
+ sessionRevision++;
1102
+ clearSignOutMarker();
1103
+ cookieSessionCandidate = !result.accessToken;
1104
+ tokenStorage.setAccessToken(result.accessToken || null);
1105
+ tokenStorage.setRefreshToken(result.refreshToken || null);
1106
+ tokenStorage.setUser(result.user);
1107
+ setState({
1108
+ status: 'authenticated',
1109
+ loading: false,
1110
+ authenticated: true,
1111
+ user: result.user,
1112
+ });
1113
+ emitEvent({ type: 'register', user: result.user });
1114
+ scheduleRefresh();
1115
+ return result;
1116
+ }
1117
+ catch (err) {
1118
+ const failure = await snapshotAuthFailure(err);
1119
+ if (!operation.isCurrent())
1120
+ throw failure;
1121
+ setState({ loading: false, error: failure.message });
1122
+ emitEvent({ type: 'error', error: failure.message });
1123
+ throw failure;
1124
+ }
1125
+ });
1126
+ },
1127
+ async refresh() {
1128
+ return runSessionOperation(async (operation) => {
1129
+ const refreshToken = tokenStorage.getRefreshToken();
1130
+ if (!refreshToken) {
1131
+ // Cookie-session APIs (the @molecule/api-resource-user model) issue NO
1132
+ // refresh token at all — login/register return only { accessToken, user }
1133
+ // and the durable credential is the httpOnly session cookie. For them,
1134
+ // "refresh" means re-deriving the session from that cookie, exactly like
1135
+ // initialize()'s restore. Without this fallback every post-auth refresh
1136
+ // (the auth modal's finishAuth, the upgrade-tab focus-return) threw
1137
+ // 'No refresh token available' — which silently aborted the modal's
1138
+ // signup/login flow right after the account was created.
1139
+ const outcome = await restoreFromSessionCookie();
1140
+ if (outcome.user) {
1141
+ if (operation.isCurrent())
1142
+ emitEvent({ type: 'refresh' });
1143
+ return { user: outcome.user };
1144
+ }
1145
+ if (outcome.rejected) {
1146
+ if (operation.isCurrent())
1147
+ endLocalSession();
1148
+ throw outcome.failure;
1149
+ }
1150
+ if (outcome.error !== undefined) {
1151
+ if (operation.isCurrent()) {
1152
+ setState({ initialized: true, status: 'unavailable', error: outcome.error });
1153
+ }
1154
+ throw outcome.failure;
1155
+ }
1156
+ if (outcome.declined && operation.isCurrent())
1157
+ endLocalSession();
1158
+ throw new I18nError('auth.error.noRefreshToken', undefined, 'No refresh token available');
1159
+ }
1160
+ let raw;
1161
+ try {
1162
+ raw = await fetchAPI(refreshEndpoint, {
1163
+ method: 'POST',
1164
+ body: JSON.stringify({ refreshToken }),
1165
+ });
1166
+ }
1167
+ catch (error) {
1168
+ if (operation.isCurrent()) {
1169
+ const status = error.status;
1170
+ if (status === 401 || status === 403)
1171
+ endLocalSession();
1172
+ else {
1173
+ setState({
1174
+ status: 'unavailable',
1175
+ error: error instanceof Error ? error.message : 'The session could not be confirmed',
1176
+ });
1177
+ }
1178
+ }
1179
+ throw error;
1180
+ }
1181
+ // Normalize credentials and profile together. A mapper for another answer
1182
+ // may not understand this response; preserve the standard wire result then.
1183
+ let result = raw;
1184
+ if (config.mapAuthResponse) {
1185
+ try {
1186
+ const mapped = mapResult(raw);
1187
+ result = {
1188
+ ...raw,
1189
+ ...mapped,
1190
+ accessToken: mapped?.accessToken ?? raw.accessToken,
1191
+ refreshToken: mapped?.refreshToken ?? raw.refreshToken,
1192
+ user: mapped?.user ?? raw.user,
1193
+ };
1194
+ }
1195
+ catch (error) {
1196
+ logger.debug('mapAuthResponse could not read the refresh answer; using it as given', error);
1197
+ }
1198
+ }
1199
+ const user = result?.user ?? null;
1200
+ if (!operation.isCurrent()) {
1201
+ discardLateSession(operation, result);
1202
+ return result;
1203
+ }
1204
+ clearSignOutMarker();
1205
+ cookieSessionCandidate = !result.accessToken;
816
1206
  tokenStorage.setAccessToken(result.accessToken || null);
817
- tokenStorage.setRefreshToken(result.refreshToken || null);
818
- tokenStorage.setUser(result.user);
1207
+ if (result.refreshToken) {
1208
+ tokenStorage.setRefreshToken(result.refreshToken);
1209
+ }
1210
+ if (user)
1211
+ tokenStorage.setUser(user);
819
1212
  setState({
820
1213
  status: 'authenticated',
821
- loading: false,
822
1214
  authenticated: true,
823
- user: result.user,
1215
+ user,
1216
+ error: null,
824
1217
  });
825
- emitEvent({ type: 'register', user: result.user });
1218
+ emitEvent({ type: 'refresh' });
826
1219
  scheduleRefresh();
827
1220
  return result;
828
- }
829
- catch (err) {
830
- const error = err instanceof Error ? err.message : 'Registration failed';
831
- setState({ loading: false, error });
832
- emitEvent({ type: 'error', error });
833
- throw err;
834
- }
835
- },
836
- async refresh() {
837
- const refreshToken = tokenStorage.getRefreshToken();
838
- if (!refreshToken) {
839
- // Cookie-session APIs (the @molecule/api-resource-user model) issue NO
840
- // refresh token at all — login/register return only { accessToken, user }
841
- // and the durable credential is the httpOnly session cookie. For them,
842
- // "refresh" means re-deriving the session from that cookie, exactly like
843
- // initialize()'s restore. Without this fallback every post-auth refresh
844
- // (the auth modal's finishAuth, the upgrade-tab focus-return) threw
845
- // 'No refresh token available' — which silently aborted the modal's
846
- // signup/login flow right after the account was created.
847
- const outcome = await restoreFromSessionCookie();
848
- if (outcome.user) {
849
- emitEvent({ type: 'refresh' });
850
- return { user: outcome.user };
851
- }
852
- throw new I18nError('auth.error.noRefreshToken', undefined, 'No refresh token available');
853
- }
854
- const result = await fetchAPI(refreshEndpoint, {
855
- method: 'POST',
856
- body: JSON.stringify({ refreshToken }),
857
1221
  });
858
- // The refresh answer carries the user too, in the same wire shape a
859
- // sign-in's does — route it through the same mapper so a token refresh
860
- // cannot replace the adapted profile with the raw one.
861
- const user = mapCurrentUser(result, (result?.user ?? null));
862
- tokenStorage.setAccessToken(result.accessToken || null);
863
- if (result.refreshToken) {
864
- tokenStorage.setRefreshToken(result.refreshToken);
865
- }
866
- if (user)
867
- tokenStorage.setUser(user);
868
- setState({
869
- status: 'authenticated',
870
- authenticated: true,
871
- user,
872
- });
873
- emitEvent({ type: 'refresh' });
874
- scheduleRefresh();
875
- return { ...result, user: user ?? result.user };
876
1222
  },
877
1223
  async requestPasswordReset(data) {
878
- await fetchAPI(forgotPasswordEndpoint, {
1224
+ await runAuthOperation(() => fetchAPI(forgotPasswordEndpoint, {
879
1225
  method: 'POST',
880
1226
  body: JSON.stringify(data),
881
- });
1227
+ }));
882
1228
  },
883
1229
  async confirmPasswordReset(data) {
884
- await fetchAPI(resetPasswordEndpoint, {
1230
+ await runAuthOperation(() => fetchAPI(resetPasswordEndpoint, {
885
1231
  method: 'POST',
886
1232
  body: JSON.stringify(data),
887
- });
1233
+ }));
888
1234
  },
889
1235
  async updateProfile(data) {
890
- const revision = sessionRevision;
891
- // The REQUEST transport is configurable (`profileUpdateMethod` /
892
- // `profileRequestBody`); everything below — mapping the answer, storing
893
- // the user, notifying subscribers — is the same session state for every
894
- // transport, so a PUT/enveloped API does not need a hand-written client.
895
- // Defaults keep the resource-style PATCH with bare data.
896
- const profileUser = state.user;
897
- if (typeof profileEndpoint === 'function' && !profileUser)
898
- throw new I18nError('auth.error.unauthorized', undefined, 'Sign in before updating a profile');
899
- const endpoint = typeof profileEndpoint === 'function' ? profileEndpoint(profileUser) : profileEndpoint;
900
- if (typeof profileEndpoint === 'function' &&
901
- (typeof endpoint !== 'string' || !endpoint.trim()))
902
- throw new TypeError('profileEndpoint must resolve to a nonempty route');
903
- const body = profileRequestBody ? profileRequestBody(data) : data;
904
- // Body adapters may return custom serializers; finish caller code before
905
- // checking which session will authorize the request.
906
- const serializedBody = JSON.stringify(body);
907
- if (revision !== sessionRevision)
908
- throw new I18nError('auth.error.unauthorized', undefined, 'The profile session changed before submission');
909
- const answer = await fetchAPI(endpoint, {
910
- method: profileUpdateMethod,
911
- body: serializedBody,
1236
+ return runAuthOperation(async () => {
1237
+ const revision = sessionRevision;
1238
+ // The REQUEST transport is configurable (`profileUpdateMethod` /
1239
+ // `profileRequestBody`); everything below — mapping the answer, storing
1240
+ // the user, notifying subscribers — is the same session state for every
1241
+ // transport, so a PUT/enveloped API does not need a hand-written client.
1242
+ // Defaults keep the resource-style PATCH with bare data.
1243
+ const profileUser = state.user;
1244
+ if (typeof profileEndpoint === 'function' && !profileUser)
1245
+ throw new I18nError('auth.error.unauthorized', undefined, 'Sign in before updating a profile');
1246
+ const endpoint = typeof profileEndpoint === 'function' ? profileEndpoint(profileUser) : profileEndpoint;
1247
+ if (typeof profileEndpoint === 'function' &&
1248
+ (typeof endpoint !== 'string' || !endpoint.trim()))
1249
+ throw new TypeError('profileEndpoint must resolve to a nonempty route');
1250
+ const body = profileRequestBody ? profileRequestBody(data) : data;
1251
+ // Body adapters may return custom serializers; finish caller code before
1252
+ // checking which session will authorize the request.
1253
+ const serializedBody = JSON.stringify(body);
1254
+ if (revision !== sessionRevision)
1255
+ throw new I18nError('auth.error.unauthorized', undefined, 'The profile session changed before submission');
1256
+ const answer = await fetchAPI(endpoint, {
1257
+ method: profileUpdateMethod,
1258
+ body: serializedBody,
1259
+ });
1260
+ // The profile route answers in one of three shapes: a bare user, `{ user }`, or
1261
+ // the resource envelope `{ props }` (which `fetchAPI` aliases onto `user`).
1262
+ // The profile the app stores is the USER, never the envelope around it —
1263
+ // caching the envelope meant every page reading `user.name` read `undefined`
1264
+ // right after a successful save (the settings-edit class).
1265
+ const unwrapped = answer;
1266
+ const raw = unwrapped.user ?? answer;
1267
+ // The profile route answers the wire shape as well; map it like every
1268
+ // other answer that replaces the cached user.
1269
+ const mapped = mapCurrentUser(answer, raw) ?? raw;
1270
+ // The server may have accepted the old account's update, but that answer
1271
+ // must never replace a signed-out or newly signed-in account's cached
1272
+ // user. Return the request's result, applied to the profile it was sent
1273
+ // for, while leaving the current session (storage and subscribers) untouched.
1274
+ if (revision !== sessionRevision)
1275
+ return applyProfileAnswer(profileUser, mapped);
1276
+ // A changed-fields answer stored as the user dropped every field the save did not
1277
+ // touch from every `useAuth()` reader, the stored session and the next save's route
1278
+ // (`/users/undefined`). Apply it to the session's CURRENT user — a same-session
1279
+ // refresh that landed during the save is kept — and store and publish the result
1280
+ // once, so no reader ever sees the partial answer.
1281
+ const user = applyProfileAnswer(state.user, mapped);
1282
+ profileRevision++;
1283
+ tokenStorage.setUser(user);
1284
+ setState({ user });
1285
+ return user;
912
1286
  });
913
- // The profile route answers in one of three shapes: a bare user, `{ user }`, or
914
- // the resource envelope `{ props }` (which `fetchAPI` aliases onto `user`).
915
- // The profile the app stores is the USER, never the envelope around it —
916
- // caching the envelope meant every page reading `user.name` read `undefined`
917
- // right after a successful save (the settings-edit class).
918
- const unwrapped = answer;
919
- const raw = unwrapped.user ?? answer;
920
- // The profile route answers the wire shape as well; map it like every
921
- // other answer that replaces the cached user.
922
- const mapped = mapCurrentUser(answer, raw) ?? raw;
923
- // The server may have accepted the old account's update, but that answer
924
- // must never replace a signed-out or newly signed-in account's cached
925
- // user. Return the request's result, applied to the profile it was sent
926
- // for, while leaving the current session (storage and subscribers) untouched.
927
- if (revision !== sessionRevision)
928
- return applyProfileAnswer(profileUser, mapped);
929
- // A changed-fields answer stored as the user dropped every field the save did not
930
- // touch from every `useAuth()` reader, the stored session and the next save's route
931
- // (`/users/undefined`). Apply it to the session's CURRENT user — a same-session
932
- // refresh that landed during the save is kept — and store and publish the result
933
- // once, so no reader ever sees the partial answer.
934
- const user = applyProfileAnswer(state.user, mapped);
935
- tokenStorage.setUser(user);
936
- setState({ user });
937
- return user;
938
1287
  },
939
1288
  async changePassword(oldPassword, newPassword) {
940
1289
  // Server handler (@molecule/api-resource-user `updatePassword`)
941
1290
  // reads `currentPassword`; legacy callers passed `oldPassword`,
942
1291
  // so accept either name in the hook signature but translate to
943
1292
  // the canonical `currentPassword` field when sending.
944
- await fetchAPI(changePasswordEndpoint, {
945
- method: 'POST',
946
- body: JSON.stringify({ currentPassword: oldPassword, newPassword }),
1293
+ await runAuthOperation(async () => {
1294
+ const revision = sessionRevision;
1295
+ const user = state.user;
1296
+ if (typeof changePasswordEndpoint === 'function' && !user)
1297
+ throw new I18nError('auth.error.unauthorized', undefined, 'Sign in before changing a password');
1298
+ const endpoint = typeof changePasswordEndpoint === 'function'
1299
+ ? changePasswordEndpoint(user)
1300
+ : changePasswordEndpoint;
1301
+ if (typeof changePasswordEndpoint === 'function' &&
1302
+ (typeof endpoint !== 'string' || !endpoint.trim()))
1303
+ throw new TypeError('changePasswordEndpoint must resolve to a nonempty route');
1304
+ if (revision !== sessionRevision)
1305
+ throw new I18nError('auth.error.unauthorized', undefined, 'The password session changed before submission');
1306
+ await fetchAPI(endpoint, {
1307
+ method: changePasswordMethod,
1308
+ body: JSON.stringify({ currentPassword: oldPassword, newPassword }),
1309
+ });
947
1310
  });
948
1311
  },
949
1312
  async initialize() {
@@ -959,85 +1322,92 @@ export const createJWTAuthClient = (config = {}) => {
959
1322
  return restoreInFlight ? restoreInFlight.catch(() => undefined) : Promise.resolve();
960
1323
  },
961
1324
  async validateSession() {
962
- const token = tokenStorage.getAccessToken();
963
- if (!token) {
964
- setState({
965
- initialized: true,
966
- authenticated: false,
967
- user: null,
968
- status: 'unauthenticated',
969
- });
970
- return;
971
- }
972
- const revision = sessionRevision;
973
- const isCurrent = () => revision === sessionRevision && token === tokenStorage.getAccessToken();
974
- try {
975
- // `fetchAPI` sends the stored token, so this asks the server "is this
976
- // session still valid?" — the background check `initialize()` does not do
977
- // (it restores from storage without a request by design).
978
- const result = await fetchAPI(currentUserEndpoint, { method: 'GET' });
979
- if (!isCurrent())
1325
+ await runSessionOperation(async (operation) => {
1326
+ const token = tokenStorage.getAccessToken();
1327
+ if (!token) {
1328
+ const outcome = await restoreFromSessionCookie();
1329
+ if (!operation.isCurrent() || outcome.stale)
1330
+ return;
1331
+ if (outcome.rejected || outcome.declined) {
1332
+ endLocalSession();
1333
+ }
1334
+ else if (outcome.error !== undefined) {
1335
+ setState({ initialized: true, status: 'unavailable', error: outcome.error });
1336
+ }
1337
+ else if (outcome.user) {
1338
+ setState({ initialized: true });
1339
+ }
1340
+ else {
1341
+ setState({
1342
+ initialized: true,
1343
+ authenticated: false,
1344
+ user: null,
1345
+ status: 'unauthenticated',
1346
+ });
1347
+ }
980
1348
  return;
981
- // The answer is the wire user, not the app's adapted profile: pass it
982
- // through the same `mapAuthResponse` a sign-in uses before storing it
983
- // (xr-molecule-flash-m184 — a reload replaced the adapted user with the
984
- // raw one and the profile link disappeared).
985
- const user = mapCurrentUser(result, (result?.user ?? tokenStorage.getUser()));
986
- if (user) {
987
- tokenStorage.setUser(user);
988
- setState({
989
- initialized: true,
990
- authenticated: true,
991
- user,
992
- status: 'authenticated',
993
- error: null,
994
- });
995
- }
996
- else {
997
- tokenStorage.clear();
998
- setState({
999
- initialized: true,
1000
- authenticated: false,
1001
- user: null,
1002
- status: 'unauthenticated',
1003
- });
1004
1349
  }
1005
- }
1006
- catch (error) {
1007
- if (!isCurrent())
1008
- return;
1009
- const httpStatus = error.status;
1010
- // ONLY 401 and 403 are the server rejecting this token (revoked, expired,
1011
- // a reset database): drop the session rather than keep sending a header
1012
- // the server refuses. Any other status is not about the credential —
1013
- // most importantly a 404, which means `currentUserEndpoint` does not
1014
- // exist on this API. A route-not-found neither verifies nor rejects the
1015
- // token, so clearing it here signed every user out of a working session
1016
- // over a configuration mistake.
1017
- if (httpStatus === 401 || httpStatus === 403) {
1018
- tokenStorage.clear();
1019
- setState({
1020
- initialized: true,
1021
- authenticated: false,
1022
- user: null,
1023
- status: 'unauthenticated',
1024
- });
1025
- return;
1350
+ const readProfileRevision = profileRevision;
1351
+ const isCurrent = () => operation.isCurrent() && token === tokenStorage.getAccessToken();
1352
+ const isCurrentProfileRead = () => isCurrent() && readProfileRevision === profileRevision;
1353
+ try {
1354
+ // `fetchAPI` sends the stored token, so this asks the server "is this
1355
+ // session still valid?" — the background check `initialize()` does not do
1356
+ // (it restores from storage without a request by design).
1357
+ const result = await fetchAPI(currentUserEndpoint, { method: 'GET' });
1358
+ if (!isCurrent())
1359
+ return;
1360
+ // The answer is the wire user, not the app's adapted profile: pass it
1361
+ // through the same `mapAuthResponse` a sign-in uses before storing it
1362
+ // so a reload keeps the adapted identity and its account controls.
1363
+ const user = mapCurrentUser(result, (result?.user ?? tokenStorage.getUser()));
1364
+ if (!isCurrentProfileRead())
1365
+ return;
1366
+ if (user) {
1367
+ clearSignOutMarker();
1368
+ tokenStorage.setUser(user);
1369
+ setState({
1370
+ initialized: true,
1371
+ authenticated: true,
1372
+ user,
1373
+ status: 'authenticated',
1374
+ error: null,
1375
+ });
1376
+ }
1377
+ else {
1378
+ endLocalSession();
1379
+ }
1026
1380
  }
1027
- // The server could not confirm the session. Keep the token AND the user
1028
- // so a later reload or a retry can restore them; say so with
1029
- // `unavailable` instead of signing the user out over a network failure,
1030
- // a 5xx, or a misplaced current-user route.
1031
- const message = httpStatus === 404
1032
- ? "The current-user endpoint was not found. Set currentUserEndpoint to your API's current-user route; the stored token was kept."
1033
- : error instanceof Error
1034
- ? error.message
1035
- : 'Session could not be confirmed';
1036
- if (httpStatus === 404) {
1037
- logger.warn(`validateSession could not verify the session — the current-user endpoint "${currentUserEndpoint}" was not found. Set currentUserEndpoint to your API's current-user route; the stored token was kept.`, error);
1381
+ catch (error) {
1382
+ if (!isCurrentProfileRead())
1383
+ return;
1384
+ const httpStatus = error.status;
1385
+ // ONLY 401 and 403 are the server rejecting this token (revoked, expired,
1386
+ // a reset database): drop the session rather than keep sending a header
1387
+ // the server refuses. Any other status is not about the credential —
1388
+ // most importantly a 404, which means `currentUserEndpoint` does not
1389
+ // exist on this API. A route-not-found neither verifies nor rejects the
1390
+ // token, so clearing it here signed every user out of a working session
1391
+ // over a configuration mistake.
1392
+ if (httpStatus === 401 || httpStatus === 403) {
1393
+ endLocalSession();
1394
+ return;
1395
+ }
1396
+ // The server could not confirm the session. Keep the token AND the user
1397
+ // so a later reload or a retry can restore them; say so with
1398
+ // `unavailable` instead of signing the user out over a network failure,
1399
+ // a 5xx, or a misplaced current-user route.
1400
+ const message = httpStatus === 404
1401
+ ? "The current-user endpoint was not found. Set currentUserEndpoint to your API's current-user route; the stored token was kept."
1402
+ : error instanceof Error
1403
+ ? error.message
1404
+ : 'Session could not be confirmed';
1405
+ if (httpStatus === 404) {
1406
+ logger.warn(`validateSession could not verify the session — the current-user endpoint "${currentUserEndpoint}" was not found. Set currentUserEndpoint to your API's current-user route; the stored token was kept.`, error);
1407
+ }
1408
+ setState({ status: 'unavailable', error: message });
1038
1409
  }
1039
- setState({ status: 'unavailable', error: message });
1040
- }
1410
+ });
1041
1411
  },
1042
1412
  subscribe(callback) {
1043
1413
  // Subscribing is a read of the state in all but name: a component that only