@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/README.md +210 -101
- package/dist/browser-storage.d.ts +5 -4
- package/dist/browser-storage.d.ts.map +1 -1
- package/dist/browser-storage.js +5 -4
- package/dist/browser-storage.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +718 -348
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +86 -30
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -30
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +121 -31
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities.d.ts +1 -1
- package/dist/utilities.js +1 -1
- package/package.json +7 -5
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
// (
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
//
|
|
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
|
|
418
|
-
// `message` into the I18nError it throws,
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
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
|
-
|
|
717
|
+
}
|
|
718
|
+
cookieSessionCandidate = true;
|
|
472
719
|
const token = tokenStorage.getAccessToken();
|
|
473
|
-
const
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
839
|
+
if (!operation.isCurrent())
|
|
570
840
|
return;
|
|
571
841
|
setState({ initialized: true });
|
|
572
842
|
return;
|
|
573
843
|
}
|
|
574
844
|
catch (error) {
|
|
575
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
889
|
+
if (!operation.isCurrent())
|
|
627
890
|
return;
|
|
628
891
|
setState({ initialized: true });
|
|
629
892
|
return;
|
|
630
893
|
}
|
|
631
|
-
await restoreFromSessionCookie();
|
|
632
|
-
if (
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
//
|
|
771
|
-
//
|
|
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
|
-
|
|
781
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
|
1215
|
+
user,
|
|
1216
|
+
error: null,
|
|
824
1217
|
});
|
|
825
|
-
emitEvent({ type: '
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
(typeof
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
tokenStorage.
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
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
|