@cellarnode/auth 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -2
- package/dist/auth-api.d.ts.map +1 -1
- package/dist/auth-api.js +44 -5
- package/dist/auth-api.js.map +1 -1
- package/dist/auth-client.d.ts.map +1 -1
- package/dist/auth-client.js +61 -17
- package/dist/auth-client.js.map +1 -1
- package/dist/auth-store.d.ts +2 -2
- package/dist/auth-store.d.ts.map +1 -1
- package/dist/auth-store.js +630 -229
- package/dist/auth-store.js.map +1 -1
- package/dist/auth-transport.d.ts +5 -0
- package/dist/auth-transport.d.ts.map +1 -0
- package/dist/auth-transport.js +45 -0
- package/dist/auth-transport.js.map +1 -0
- package/dist/auth-user.d.ts +7 -0
- package/dist/auth-user.d.ts.map +1 -0
- package/dist/auth-user.js +73 -0
- package/dist/auth-user.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/react/login-form.js +1 -1
- package/dist/react/login-form.js.map +1 -1
- package/dist/session-continuity.d.ts +13 -0
- package/dist/session-continuity.d.ts.map +1 -0
- package/dist/session-continuity.js +107 -0
- package/dist/session-continuity.js.map +1 -0
- package/dist/types.d.ts +79 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/auth-store.js
CHANGED
|
@@ -1,44 +1,8 @@
|
|
|
1
|
+
import { copyAuthUser, parseAuthUser } from "./auth-user.js";
|
|
1
2
|
import { extractAccessToken } from "./extract-token.js";
|
|
2
|
-
|
|
3
|
-
* Map a `/auth/me` AuthUser onto the minimal cached identity.
|
|
4
|
-
*
|
|
5
|
-
* `AuthUser.id` is the userId. `entitlements` is optional on the wire (older
|
|
6
|
-
* backends omit it) → defaults to `[]`. The element filter guards against a
|
|
7
|
-
* malformed array carrying non-string entries.
|
|
8
|
-
*/
|
|
9
|
-
function toIdentity(user) {
|
|
10
|
-
const entitlements = Array.isArray(user.entitlements)
|
|
11
|
-
? user.entitlements.filter((e) => typeof e === "string")
|
|
12
|
-
: [];
|
|
13
|
-
return {
|
|
14
|
-
userId: user.id,
|
|
15
|
-
orgId: user.orgId ?? null,
|
|
16
|
-
userType: user.userType,
|
|
17
|
-
entitlements,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Fallback access-token lifetime, in seconds, when the server omits
|
|
22
|
-
* `expiresIn`. Mirrors the verify-otp adoption path in `auth-api.ts`.
|
|
23
|
-
*/
|
|
3
|
+
import { fetchAuthRequest } from "./auth-transport.js";
|
|
24
4
|
const DEFAULT_ACCESS_TOKEN_TTL = 900;
|
|
25
|
-
|
|
26
|
-
* Developer-facing copy for each `devLogin()` failure (CEL-1364).
|
|
27
|
-
*
|
|
28
|
-
* The 404 message frames the outcome as "the backend gate is off" and nothing
|
|
29
|
-
* else. The backend deliberately returns an identical 404 for "gate off" and
|
|
30
|
-
* "no such user" (T3-1), so any copy that named the account would be both a
|
|
31
|
-
* guess and a weakening of that contract.
|
|
32
|
-
*
|
|
33
|
-
* These strings SHIP in production bundles — they are referenced from
|
|
34
|
-
* `devLogin`'s live body, which no bundler can prove unreachable. The
|
|
35
|
-
* `ENABLE_TEST_ENDPOINTS` mention is therefore public, which is fine: the flag
|
|
36
|
-
* is documented in this package's README and in the backend repo, and knowing
|
|
37
|
-
* its name grants nothing when the route is not mounted. Moving the copy behind
|
|
38
|
-
* the DEV-only React module would eliminate it, but only by taking the
|
|
39
|
-
* ready-to-render `message` off `DevLoginFailure` — a public-API change, not a
|
|
40
|
-
* review fixup.
|
|
41
|
-
*/
|
|
5
|
+
const DEFAULT_RESOLUTION_TIMEOUT_MS = 10_000;
|
|
42
6
|
const DEV_LOGIN_MESSAGES = {
|
|
43
7
|
"test-endpoints-disabled": "Dev sign-in unavailable: backend test endpoints are disabled. Set ENABLE_TEST_ENDPOINTS=true on the API and restart it.",
|
|
44
8
|
"rate-limited": "Dev sign-in rate limit hit (5/min). Wait a minute and retry.",
|
|
@@ -46,239 +10,681 @@ const DEV_LOGIN_MESSAGES = {
|
|
|
46
10
|
network: "Dev sign-in could not reach the API. Is the backend running?",
|
|
47
11
|
"malformed-response": "Dev sign-in succeeded but the API returned no access token.",
|
|
48
12
|
};
|
|
13
|
+
function copySessionState(state) {
|
|
14
|
+
return state.status === "ready"
|
|
15
|
+
? { ...state, user: copyAuthUser(state.user) }
|
|
16
|
+
: { ...state };
|
|
17
|
+
}
|
|
18
|
+
function copyResolution(resolution) {
|
|
19
|
+
return resolution.status === "ready"
|
|
20
|
+
? { ...resolution, user: copyAuthUser(resolution.user) }
|
|
21
|
+
: { ...resolution };
|
|
22
|
+
}
|
|
49
23
|
export function createAuthStore(config) {
|
|
50
|
-
const { baseUrl, refreshPath = "/auth/refresh", refreshBuffer = 60 } = config;
|
|
24
|
+
const { baseUrl, refreshPath = "/auth/refresh", revalidatePath = "/auth/revalidate", refreshBuffer = 60, resolutionTimeoutMs = DEFAULT_RESOLUTION_TIMEOUT_MS, } = config;
|
|
25
|
+
const requestTimeoutMs = Number.isFinite(resolutionTimeoutMs) && resolutionTimeoutMs > 0
|
|
26
|
+
? resolutionTimeoutMs
|
|
27
|
+
: DEFAULT_RESOLUTION_TIMEOUT_MS;
|
|
51
28
|
let accessToken = null;
|
|
52
29
|
let identity = null;
|
|
53
30
|
let refreshTimer = null;
|
|
54
|
-
let
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// identity (orgId null) and an admin login right after `clearToken()` would
|
|
60
|
-
// skip `onOrgChange(null)`. Both are reset on logout so a subsequent login
|
|
61
|
-
// re-fires `onOrgChange` even into the same org.
|
|
31
|
+
let identityFlight = null;
|
|
32
|
+
let refreshFlight = null;
|
|
33
|
+
let authorityFlight = null;
|
|
34
|
+
let explicitAdoptionFlight = null;
|
|
35
|
+
let tokenGeneration = 0;
|
|
62
36
|
let previousOrgId = null;
|
|
63
37
|
let hasEmittedOrgId = false;
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
38
|
+
// Last validated principal/tenant survives transient authority outages. It
|
|
39
|
+
// never powers authority getters; it only prevents raw same-token identity
|
|
40
|
+
// changes from becoming trusted after an unavailable read.
|
|
41
|
+
let continuityBaseline = null;
|
|
42
|
+
// A refreshed credential may be adopted before its identity read succeeds.
|
|
43
|
+
// Preserve its old-principal lineage so a later retry still verifies account
|
|
44
|
+
// continuity while permitting a fresh-token organisation transition.
|
|
45
|
+
let pendingRefreshBaseline = null;
|
|
46
|
+
let sessionState = { status: "unauthorized" };
|
|
69
47
|
const orgChangeListeners = new Set();
|
|
70
48
|
const accessTokenSetListeners = new Set();
|
|
71
49
|
const logoutListeners = new Set();
|
|
72
|
-
|
|
50
|
+
const sessionStateListeners = new Set();
|
|
51
|
+
const stateQueue = [];
|
|
52
|
+
let publishingState = false;
|
|
53
|
+
function settleExplicitGeneration(generation, resolution) {
|
|
54
|
+
if (explicitAdoptionFlight?.generation === generation) {
|
|
55
|
+
explicitAdoptionFlight.settle(resolution);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function emitOrgChange(orgId, shouldContinue = () => true) {
|
|
73
59
|
for (const listener of orgChangeListeners) {
|
|
74
60
|
try {
|
|
75
61
|
listener(orgId);
|
|
76
62
|
}
|
|
77
63
|
catch {
|
|
78
|
-
//
|
|
79
|
-
// break event fan-out to other subscribers.
|
|
64
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
80
65
|
}
|
|
66
|
+
if (!shouldContinue())
|
|
67
|
+
break;
|
|
81
68
|
}
|
|
82
69
|
}
|
|
83
|
-
function emitAccessTokenSet(token) {
|
|
70
|
+
function emitAccessTokenSet(token, shouldContinue = () => true) {
|
|
84
71
|
for (const listener of accessTokenSetListeners) {
|
|
85
72
|
try {
|
|
86
73
|
listener(token);
|
|
87
74
|
}
|
|
88
75
|
catch {
|
|
89
|
-
//
|
|
76
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
90
77
|
}
|
|
78
|
+
if (!shouldContinue())
|
|
79
|
+
break;
|
|
91
80
|
}
|
|
92
81
|
}
|
|
93
|
-
function emitLogout() {
|
|
82
|
+
function emitLogout(shouldContinue = () => true) {
|
|
94
83
|
for (const listener of logoutListeners) {
|
|
95
84
|
try {
|
|
96
85
|
listener();
|
|
97
86
|
}
|
|
98
87
|
catch {
|
|
99
|
-
//
|
|
88
|
+
// Subscriber failures cannot break other observers or logout.
|
|
89
|
+
}
|
|
90
|
+
if (!shouldContinue())
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function publishSessionState(next) {
|
|
95
|
+
stateQueue.push(copySessionState(next));
|
|
96
|
+
if (publishingState)
|
|
97
|
+
return;
|
|
98
|
+
publishingState = true;
|
|
99
|
+
try {
|
|
100
|
+
while (stateQueue.length > 0) {
|
|
101
|
+
const published = stateQueue.shift();
|
|
102
|
+
if (!published)
|
|
103
|
+
continue;
|
|
104
|
+
sessionState = published;
|
|
105
|
+
for (const listener of sessionStateListeners) {
|
|
106
|
+
const queuedBeforeCallback = stateQueue.length;
|
|
107
|
+
try {
|
|
108
|
+
listener(copySessionState(published));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Observers cannot veto or interrupt global session resolution.
|
|
112
|
+
}
|
|
113
|
+
if (stateQueue.length > queuedBeforeCallback)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
100
116
|
}
|
|
101
117
|
}
|
|
118
|
+
finally {
|
|
119
|
+
publishingState = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Notify consumers before authority or credentials can change. */
|
|
123
|
+
function beginResolving(generation, token) {
|
|
124
|
+
publishSessionState({ status: "resolving", token });
|
|
125
|
+
if (generation !== tokenGeneration)
|
|
126
|
+
return false;
|
|
127
|
+
identity = null;
|
|
128
|
+
return true;
|
|
102
129
|
}
|
|
103
130
|
function scheduleRefresh(expiresInSeconds) {
|
|
104
131
|
if (refreshTimer)
|
|
105
132
|
clearTimeout(refreshTimer);
|
|
106
133
|
const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
|
|
107
134
|
refreshTimer = setTimeout(() => {
|
|
108
|
-
|
|
135
|
+
void store.resolveSession({ refresh: true });
|
|
109
136
|
}, delay);
|
|
110
137
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
function withResolutionTimeout(operation) {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
controller.abort();
|
|
143
|
+
reject(new Error("Session resolution request timed out"));
|
|
144
|
+
}, requestTimeoutMs);
|
|
145
|
+
let pending;
|
|
146
|
+
try {
|
|
147
|
+
pending = operation(controller.signal);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
void pending.then((value) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
resolve(value);
|
|
157
|
+
}, (error) => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
reject(error);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function fetchResolutionResponse(path, init) {
|
|
164
|
+
return withResolutionTimeout(async (signal) => {
|
|
165
|
+
const response = await fetchAuthRequest(baseUrl, path, {
|
|
166
|
+
...init,
|
|
167
|
+
signal,
|
|
168
|
+
});
|
|
169
|
+
const raw = response.ok ? await response.json() : null;
|
|
170
|
+
return { response, raw };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
120
173
|
async function fetchIdentity(token) {
|
|
174
|
+
let result;
|
|
121
175
|
try {
|
|
122
|
-
|
|
176
|
+
result = await fetchResolutionResponse("/auth/me", {
|
|
123
177
|
method: "GET",
|
|
124
178
|
credentials: "include",
|
|
125
179
|
headers: { Authorization: `Bearer ${token}` },
|
|
126
180
|
});
|
|
127
|
-
if (!res.ok)
|
|
128
|
-
return null;
|
|
129
|
-
const user = (await res.json());
|
|
130
|
-
if (!user || typeof user.id !== "string" || user.id.length === 0) {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
return toIdentity(user);
|
|
134
181
|
}
|
|
135
182
|
catch {
|
|
136
|
-
return
|
|
183
|
+
return { status: "unavailable" };
|
|
137
184
|
}
|
|
185
|
+
const { response, raw } = result;
|
|
186
|
+
if (response.status === 401 || response.status === 403) {
|
|
187
|
+
return { status: "unauthorized" };
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok)
|
|
190
|
+
return { status: "unavailable" };
|
|
191
|
+
const user = parseAuthUser(raw);
|
|
192
|
+
return user
|
|
193
|
+
? { status: "ready", user }
|
|
194
|
+
: { status: "unavailable" };
|
|
138
195
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return;
|
|
196
|
+
function clearCurrentGeneration(generation) {
|
|
197
|
+
if (generation !== tokenGeneration)
|
|
198
|
+
return false;
|
|
199
|
+
tokenGeneration += 1;
|
|
200
|
+
const clearedGeneration = tokenGeneration;
|
|
201
|
+
accessToken = null;
|
|
202
|
+
identity = null;
|
|
203
|
+
continuityBaseline = null;
|
|
204
|
+
pendingRefreshBaseline = null;
|
|
205
|
+
identityFlight = null;
|
|
206
|
+
refreshFlight = null;
|
|
207
|
+
authorityFlight = null;
|
|
208
|
+
previousOrgId = null;
|
|
209
|
+
hasEmittedOrgId = false;
|
|
210
|
+
if (refreshTimer) {
|
|
211
|
+
clearTimeout(refreshTimer);
|
|
212
|
+
refreshTimer = null;
|
|
213
|
+
}
|
|
214
|
+
publishSessionState({ status: "unauthorized" });
|
|
215
|
+
if (tokenGeneration !== clearedGeneration || accessToken !== null)
|
|
216
|
+
return false;
|
|
217
|
+
emitAccessTokenSet(null, () => tokenGeneration === clearedGeneration && accessToken === null);
|
|
218
|
+
const cleared = tokenGeneration === clearedGeneration && accessToken === null;
|
|
219
|
+
if (cleared) {
|
|
220
|
+
settleExplicitGeneration(generation, { status: "unauthorized" });
|
|
221
|
+
}
|
|
222
|
+
return cleared;
|
|
223
|
+
}
|
|
224
|
+
function markUnavailable(generation, token) {
|
|
225
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
226
|
+
return { status: "superseded" };
|
|
227
|
+
}
|
|
228
|
+
identity = null;
|
|
229
|
+
publishSessionState({ status: "unavailable", token });
|
|
230
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
231
|
+
return { status: "superseded" };
|
|
232
|
+
}
|
|
233
|
+
const result = { status: "unavailable", token };
|
|
234
|
+
settleExplicitGeneration(generation, result);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
function commitReady(generation, token, user) {
|
|
238
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
239
|
+
return { status: "superseded" };
|
|
240
|
+
}
|
|
241
|
+
identity = copyAuthUser(user);
|
|
242
|
+
const nextOrgId = identity.orgId;
|
|
243
|
+
continuityBaseline = { token, user: copyAuthUser(identity) };
|
|
244
|
+
pendingRefreshBaseline = null;
|
|
245
|
+
publishSessionState({ status: "ready", token, user: identity });
|
|
246
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
247
|
+
return { status: "superseded" };
|
|
248
|
+
}
|
|
249
|
+
emitAccessTokenSet(token, () => generation === tokenGeneration && accessToken === token);
|
|
250
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
251
|
+
return { status: "superseded" };
|
|
160
252
|
}
|
|
161
|
-
identity = nextIdentity;
|
|
162
|
-
const nextOrgId = nextIdentity?.orgId ?? null;
|
|
163
|
-
// (b) Token-set first — handlers read getOrgId()/getUserId() and must see
|
|
164
|
-
// the just-cached identity.
|
|
165
|
-
emitAccessTokenSet(token);
|
|
166
|
-
// (c) Org-change on the first emission OR an actual transition. The
|
|
167
|
-
// `!hasEmittedOrgId` guard ensures the first identity (incl. an admin
|
|
168
|
-
// orgId of null) always fires once.
|
|
169
253
|
if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
|
|
170
254
|
hasEmittedOrgId = true;
|
|
171
255
|
previousOrgId = nextOrgId;
|
|
172
|
-
emitOrgChange(nextOrgId);
|
|
256
|
+
emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
|
|
257
|
+
}
|
|
258
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
259
|
+
return { status: "superseded" };
|
|
173
260
|
}
|
|
261
|
+
const result = {
|
|
262
|
+
status: "ready",
|
|
263
|
+
token,
|
|
264
|
+
user: copyAuthUser(identity),
|
|
265
|
+
};
|
|
266
|
+
settleExplicitGeneration(generation, result);
|
|
267
|
+
return result;
|
|
174
268
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
269
|
+
async function evaluateIdentity(generation, token, baseline, refreshed) {
|
|
270
|
+
const read = await fetchIdentity(token);
|
|
271
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
272
|
+
return { status: "superseded" };
|
|
273
|
+
}
|
|
274
|
+
if (read.status === "unauthorized") {
|
|
275
|
+
return clearCurrentGeneration(generation)
|
|
276
|
+
? { status: "unauthorized" }
|
|
277
|
+
: { status: "superseded" };
|
|
278
|
+
}
|
|
279
|
+
if (read.status === "unavailable") {
|
|
280
|
+
return markUnavailable(generation, token);
|
|
281
|
+
}
|
|
282
|
+
if (baseline && read.user.id !== baseline.user.id) {
|
|
283
|
+
if (!refreshed)
|
|
284
|
+
return runRefresh(generation, baseline);
|
|
285
|
+
return clearCurrentGeneration(generation)
|
|
286
|
+
? { status: "unauthorized" }
|
|
287
|
+
: { status: "superseded" };
|
|
288
|
+
}
|
|
289
|
+
if (baseline && read.user.orgId !== baseline.user.orgId) {
|
|
290
|
+
if (!refreshed) {
|
|
291
|
+
// Same-token raw membership changes are not authority. Rotate once,
|
|
292
|
+
// then validate fresh credentials before adopting the transition.
|
|
293
|
+
return runRefresh(generation, baseline);
|
|
294
|
+
}
|
|
295
|
+
if (token === baseline.token) {
|
|
296
|
+
return markUnavailable(generation, token);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return commitReady(generation, token, read.user);
|
|
300
|
+
}
|
|
301
|
+
function startIdentityFlight(generation, token, baseline, refreshed, notifyResolving) {
|
|
302
|
+
if (identityFlight?.generation === generation) {
|
|
303
|
+
return identityFlight.promise;
|
|
304
|
+
}
|
|
305
|
+
let settle;
|
|
306
|
+
const promise = new Promise((resolve) => {
|
|
307
|
+
settle = resolve;
|
|
308
|
+
});
|
|
309
|
+
const flight = { generation, promise };
|
|
310
|
+
identityFlight = flight;
|
|
311
|
+
if (notifyResolving && !beginResolving(generation, token)) {
|
|
312
|
+
if (identityFlight === flight)
|
|
313
|
+
identityFlight = null;
|
|
314
|
+
settle({ status: "superseded" });
|
|
315
|
+
return promise;
|
|
316
|
+
}
|
|
317
|
+
void evaluateIdentity(generation, token, baseline, refreshed).then((result) => {
|
|
318
|
+
if (identityFlight === flight)
|
|
319
|
+
identityFlight = null;
|
|
320
|
+
settle(result);
|
|
321
|
+
}, () => {
|
|
322
|
+
if (identityFlight === flight)
|
|
323
|
+
identityFlight = null;
|
|
324
|
+
settle(markUnavailable(generation, token));
|
|
325
|
+
});
|
|
326
|
+
return promise;
|
|
327
|
+
}
|
|
328
|
+
function currentBaseline() {
|
|
329
|
+
const baseline = pendingRefreshBaseline ?? continuityBaseline;
|
|
330
|
+
return baseline
|
|
331
|
+
? { token: baseline.token, user: copyAuthUser(baseline.user) }
|
|
332
|
+
: null;
|
|
333
|
+
}
|
|
334
|
+
function runRefresh(generation, baseline) {
|
|
335
|
+
if (refreshFlight)
|
|
336
|
+
return refreshFlight.promise;
|
|
337
|
+
if (generation !== tokenGeneration) {
|
|
338
|
+
return Promise.resolve({ status: "superseded" });
|
|
339
|
+
}
|
|
340
|
+
// Expiry renewal / forced refresh wins over an in-flight authority remint.
|
|
341
|
+
authorityFlight = null;
|
|
342
|
+
if (!baseline && identityFlight?.generation === generation) {
|
|
343
|
+
const pendingIdentity = identityFlight.promise;
|
|
344
|
+
return pendingIdentity.then((result) => {
|
|
345
|
+
if (result.status === "ready") {
|
|
346
|
+
return runRefresh(tokenGeneration, {
|
|
347
|
+
token: result.token,
|
|
348
|
+
user: copyAuthUser(result.user),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (result.status === "unavailable" &&
|
|
352
|
+
generation === tokenGeneration &&
|
|
353
|
+
accessToken !== null) {
|
|
354
|
+
return runRefresh(tokenGeneration, null);
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
let settle;
|
|
360
|
+
const promise = new Promise((resolve) => {
|
|
361
|
+
settle = resolve;
|
|
362
|
+
});
|
|
363
|
+
const flight = { generation, promise };
|
|
364
|
+
refreshFlight = flight;
|
|
365
|
+
if (!beginResolving(generation, accessToken)) {
|
|
366
|
+
if (refreshFlight === flight)
|
|
367
|
+
refreshFlight = null;
|
|
368
|
+
settle({ status: "superseded" });
|
|
369
|
+
return promise;
|
|
370
|
+
}
|
|
371
|
+
// Refresh owns a new operation generation before transport begins. Any
|
|
372
|
+
// older identity read can no longer publish authority while refresh waits.
|
|
183
373
|
tokenGeneration += 1;
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
374
|
+
const refreshGeneration = tokenGeneration;
|
|
375
|
+
flight.generation = refreshGeneration;
|
|
376
|
+
void (async () => {
|
|
377
|
+
let result;
|
|
378
|
+
try {
|
|
379
|
+
result = await fetchResolutionResponse(refreshPath, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
credentials: "include",
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
387
|
+
}
|
|
388
|
+
const { response, raw } = result;
|
|
389
|
+
if (refreshGeneration !== tokenGeneration)
|
|
390
|
+
return { status: "superseded" };
|
|
391
|
+
if (response.status === 401 || response.status === 403) {
|
|
392
|
+
return clearCurrentGeneration(refreshGeneration)
|
|
393
|
+
? { status: "unauthorized" }
|
|
394
|
+
: { status: "superseded" };
|
|
395
|
+
}
|
|
396
|
+
if (!response.ok)
|
|
397
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
398
|
+
if (refreshGeneration !== tokenGeneration)
|
|
399
|
+
return { status: "superseded" };
|
|
400
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
401
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
402
|
+
}
|
|
403
|
+
const json = raw;
|
|
404
|
+
const nextToken = extractAccessToken(json);
|
|
405
|
+
if (!nextToken)
|
|
406
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
407
|
+
const expiresIn = typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn)
|
|
408
|
+
? json.expiresIn
|
|
409
|
+
: DEFAULT_ACCESS_TOKEN_TTL;
|
|
410
|
+
tokenGeneration += 1;
|
|
411
|
+
const nextGeneration = tokenGeneration;
|
|
412
|
+
// Manual/timer callers arriving while refreshed identity still resolves
|
|
413
|
+
// join this same end-to-end refresh operation.
|
|
414
|
+
flight.generation = nextGeneration;
|
|
415
|
+
accessToken = nextToken;
|
|
416
|
+
identity = null;
|
|
417
|
+
pendingRefreshBaseline = baseline
|
|
418
|
+
? { token: baseline.token, user: copyAuthUser(baseline.user) }
|
|
419
|
+
: null;
|
|
420
|
+
publishSessionState({ status: "resolving", token: nextToken });
|
|
421
|
+
if (nextGeneration !== tokenGeneration ||
|
|
422
|
+
accessToken !== nextToken) {
|
|
423
|
+
return { status: "superseded" };
|
|
424
|
+
}
|
|
425
|
+
scheduleRefresh(expiresIn);
|
|
426
|
+
return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
|
|
427
|
+
})().then((result) => {
|
|
428
|
+
if (refreshFlight === flight)
|
|
429
|
+
refreshFlight = null;
|
|
430
|
+
settle(result);
|
|
431
|
+
}, () => {
|
|
432
|
+
if (refreshFlight === flight)
|
|
433
|
+
refreshFlight = null;
|
|
434
|
+
settle(markUnavailable(refreshGeneration, accessToken));
|
|
435
|
+
});
|
|
436
|
+
return promise;
|
|
437
|
+
}
|
|
438
|
+
function readErrorCode(raw) {
|
|
439
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
440
|
+
return undefined;
|
|
441
|
+
const code = raw.code;
|
|
442
|
+
return typeof code === "string" ? code : undefined;
|
|
195
443
|
}
|
|
196
444
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* `onAccessTokenSet(null)` fires synchronously (no `/auth/me` round-trip on
|
|
200
|
-
* logout). `previousOrgId` is reset so a subsequent login re-fires
|
|
201
|
-
* `onOrgChange` even when the user logs back into the same org.
|
|
445
|
+
* Non-rotating authority remint (CEL-1853). Preserves the absolute refresh
|
|
446
|
+
* deadline/timer — never calls scheduleRefresh with a fresh TTL.
|
|
202
447
|
*/
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
448
|
+
function runRevalidate(generation) {
|
|
449
|
+
if (authorityFlight)
|
|
450
|
+
return authorityFlight.promise;
|
|
451
|
+
if (refreshFlight)
|
|
452
|
+
return refreshFlight.promise;
|
|
453
|
+
if (explicitAdoptionFlight)
|
|
454
|
+
return explicitAdoptionFlight.promise;
|
|
455
|
+
if (generation !== tokenGeneration) {
|
|
456
|
+
return Promise.resolve({ status: "superseded" });
|
|
457
|
+
}
|
|
458
|
+
const bearerToken = accessToken;
|
|
459
|
+
if (!bearerToken)
|
|
460
|
+
return Promise.resolve({ status: "unauthorized" });
|
|
461
|
+
let settle;
|
|
462
|
+
const promise = new Promise((resolve) => {
|
|
463
|
+
settle = resolve;
|
|
464
|
+
});
|
|
465
|
+
const flight = { generation, promise };
|
|
466
|
+
authorityFlight = flight;
|
|
467
|
+
if (!beginResolving(generation, bearerToken)) {
|
|
468
|
+
if (authorityFlight === flight)
|
|
469
|
+
authorityFlight = null;
|
|
470
|
+
settle({ status: "superseded" });
|
|
471
|
+
return promise;
|
|
472
|
+
}
|
|
473
|
+
// Reserve a generation before POST so earlier /me reads cannot publish.
|
|
474
|
+
tokenGeneration += 1;
|
|
475
|
+
const opGeneration = tokenGeneration;
|
|
476
|
+
flight.generation = opGeneration;
|
|
477
|
+
const baseline = currentBaseline();
|
|
478
|
+
void (async () => {
|
|
479
|
+
let result;
|
|
480
|
+
try {
|
|
481
|
+
result = await withResolutionTimeout(async (signal) => {
|
|
482
|
+
const response = await fetchAuthRequest(baseUrl, revalidatePath, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
credentials: "omit",
|
|
485
|
+
headers: {
|
|
486
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
487
|
+
"Content-Type": "application/json",
|
|
488
|
+
},
|
|
489
|
+
signal,
|
|
490
|
+
});
|
|
491
|
+
let raw = null;
|
|
492
|
+
try {
|
|
493
|
+
raw = await response.json();
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
raw = null;
|
|
497
|
+
}
|
|
498
|
+
return { response, raw };
|
|
499
|
+
});
|
|
221
500
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (token) {
|
|
225
|
-
const expiresIn = typeof json.expiresIn === "number" ? json.expiresIn : 900;
|
|
226
|
-
applyToken(token);
|
|
227
|
-
scheduleRefresh(expiresIn);
|
|
501
|
+
catch {
|
|
502
|
+
return markUnavailable(opGeneration, accessToken);
|
|
228
503
|
}
|
|
229
|
-
|
|
504
|
+
if (opGeneration !== tokenGeneration)
|
|
505
|
+
return { status: "superseded" };
|
|
506
|
+
const { response, raw } = result;
|
|
507
|
+
const errorCode = readErrorCode(raw);
|
|
508
|
+
if (response.status === 401 || response.status === 403) {
|
|
509
|
+
// Exactly one expiry-classified fallback to ordinary refresh.
|
|
510
|
+
if (errorCode === "ACCESS_TOKEN_EXPIRED") {
|
|
511
|
+
if (authorityFlight === flight)
|
|
512
|
+
authorityFlight = null;
|
|
513
|
+
return runRefresh(tokenGeneration, baseline);
|
|
514
|
+
}
|
|
515
|
+
if (opGeneration !== tokenGeneration || accessToken !== bearerToken) {
|
|
516
|
+
return { status: "superseded" };
|
|
517
|
+
}
|
|
518
|
+
return clearCurrentGeneration(opGeneration)
|
|
519
|
+
? { status: "unauthorized" }
|
|
520
|
+
: { status: "superseded" };
|
|
521
|
+
}
|
|
522
|
+
if (!response.ok)
|
|
523
|
+
return markUnavailable(opGeneration, accessToken);
|
|
524
|
+
if (opGeneration !== tokenGeneration)
|
|
525
|
+
return { status: "superseded" };
|
|
526
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
527
|
+
return markUnavailable(opGeneration, accessToken);
|
|
528
|
+
}
|
|
529
|
+
const nextToken = extractAccessToken(raw);
|
|
530
|
+
if (!nextToken)
|
|
531
|
+
return markUnavailable(opGeneration, accessToken);
|
|
532
|
+
tokenGeneration += 1;
|
|
533
|
+
const nextGeneration = tokenGeneration;
|
|
534
|
+
flight.generation = nextGeneration;
|
|
535
|
+
accessToken = nextToken;
|
|
536
|
+
identity = null;
|
|
537
|
+
// Reminted credential: retain prior baseline for continuity, allow org
|
|
538
|
+
// transition after /me (refreshed=true). Do NOT reschedule refresh.
|
|
539
|
+
pendingRefreshBaseline = baseline
|
|
540
|
+
? { token: baseline.token, user: copyAuthUser(baseline.user) }
|
|
541
|
+
: null;
|
|
542
|
+
publishSessionState({ status: "resolving", token: nextToken });
|
|
543
|
+
if (nextGeneration !== tokenGeneration || accessToken !== nextToken) {
|
|
544
|
+
return { status: "superseded" };
|
|
545
|
+
}
|
|
546
|
+
return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
|
|
547
|
+
})().then((result) => {
|
|
548
|
+
if (authorityFlight === flight)
|
|
549
|
+
authorityFlight = null;
|
|
550
|
+
settle(result);
|
|
551
|
+
}, () => {
|
|
552
|
+
if (authorityFlight === flight)
|
|
553
|
+
authorityFlight = null;
|
|
554
|
+
settle(markUnavailable(opGeneration, accessToken));
|
|
555
|
+
});
|
|
556
|
+
return promise;
|
|
557
|
+
}
|
|
558
|
+
function resolveCurrentSession() {
|
|
559
|
+
const token = accessToken;
|
|
560
|
+
const generation = tokenGeneration;
|
|
561
|
+
if (!token)
|
|
562
|
+
return Promise.resolve({ status: "unauthorized" });
|
|
563
|
+
if (authorityFlight)
|
|
564
|
+
return authorityFlight.promise;
|
|
565
|
+
if (identityFlight?.generation === generation) {
|
|
566
|
+
return identityFlight.promise;
|
|
230
567
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
568
|
+
const baseline = currentBaseline();
|
|
569
|
+
const refreshed = pendingRefreshBaseline !== null;
|
|
570
|
+
return startIdentityFlight(generation, token, baseline, refreshed, true);
|
|
571
|
+
}
|
|
572
|
+
function waitForCaller(promise, signal) {
|
|
573
|
+
if (!signal)
|
|
574
|
+
return promise.then(copyResolution);
|
|
575
|
+
if (signal.aborted)
|
|
576
|
+
return Promise.resolve({ status: "superseded" });
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
const onAbort = () => resolve({ status: "superseded" });
|
|
579
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
580
|
+
void promise.then((result) => {
|
|
581
|
+
signal.removeEventListener("abort", onAbort);
|
|
582
|
+
resolve(copyResolution(result));
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function supersedeExplicitAdoption() {
|
|
587
|
+
const flight = explicitAdoptionFlight;
|
|
588
|
+
if (!flight)
|
|
589
|
+
return;
|
|
590
|
+
explicitAdoptionFlight = null;
|
|
591
|
+
flight.settle({ status: "superseded" });
|
|
592
|
+
}
|
|
593
|
+
function startExplicitToken(token, expiresIn) {
|
|
594
|
+
tokenGeneration += 1;
|
|
595
|
+
const generation = tokenGeneration;
|
|
596
|
+
supersedeExplicitAdoption();
|
|
597
|
+
refreshFlight = null;
|
|
598
|
+
authorityFlight = null;
|
|
599
|
+
let settled = false;
|
|
600
|
+
let resolveFlight;
|
|
601
|
+
const promise = new Promise((resolve) => {
|
|
602
|
+
resolveFlight = resolve;
|
|
603
|
+
});
|
|
604
|
+
const flight = {
|
|
605
|
+
generation,
|
|
606
|
+
promise,
|
|
607
|
+
settle(resolution) {
|
|
608
|
+
if (settled)
|
|
609
|
+
return;
|
|
610
|
+
settled = true;
|
|
611
|
+
if (explicitAdoptionFlight === flight)
|
|
612
|
+
explicitAdoptionFlight = null;
|
|
613
|
+
resolveFlight(resolution);
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
explicitAdoptionFlight = flight;
|
|
617
|
+
if (!beginResolving(generation, token)) {
|
|
618
|
+
flight.settle({ status: "superseded" });
|
|
619
|
+
return;
|
|
234
620
|
}
|
|
621
|
+
accessToken = token;
|
|
622
|
+
identity = null;
|
|
623
|
+
continuityBaseline = null;
|
|
624
|
+
pendingRefreshBaseline = null;
|
|
625
|
+
scheduleRefresh(expiresIn);
|
|
626
|
+
void startIdentityFlight(generation, token, null, false, false).then(flight.settle, () => flight.settle(markUnavailable(generation, token)));
|
|
235
627
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
hasAccessToken() {
|
|
241
|
-
return accessToken !== null;
|
|
242
|
-
},
|
|
628
|
+
let store;
|
|
629
|
+
store = {
|
|
630
|
+
getAccessToken: () => accessToken,
|
|
631
|
+
hasAccessToken: () => accessToken !== null,
|
|
243
632
|
setAccessToken(token, expiresIn) {
|
|
244
|
-
|
|
245
|
-
scheduleRefresh(expiresIn);
|
|
633
|
+
startExplicitToken(token, expiresIn);
|
|
246
634
|
},
|
|
247
635
|
clearAccessToken() {
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
636
|
+
supersedeExplicitAdoption();
|
|
637
|
+
if (clearCurrentGeneration(tokenGeneration)) {
|
|
638
|
+
const clearedGeneration = tokenGeneration;
|
|
639
|
+
emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
|
|
252
640
|
}
|
|
253
|
-
// Emits unconditionally so consumers can rely on `onLogout` as a
|
|
254
|
-
// canonical logout signal regardless of prior token state.
|
|
255
|
-
emitLogout();
|
|
256
641
|
},
|
|
257
642
|
async ensureAccessToken(forceRefresh = false) {
|
|
258
|
-
if (!forceRefresh &&
|
|
643
|
+
if (!forceRefresh &&
|
|
644
|
+
accessToken &&
|
|
645
|
+
identity &&
|
|
646
|
+
!identityFlight &&
|
|
647
|
+
!refreshFlight &&
|
|
648
|
+
!authorityFlight &&
|
|
649
|
+
sessionState.status === "ready") {
|
|
259
650
|
return accessToken;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
651
|
+
}
|
|
652
|
+
const result = forceRefresh
|
|
653
|
+
? await store.resolveSession({ refresh: true })
|
|
654
|
+
: await store.resolveSession();
|
|
655
|
+
return result.status === "ready" || result.status === "unavailable"
|
|
656
|
+
? result.token
|
|
657
|
+
: null;
|
|
658
|
+
},
|
|
659
|
+
resolveSession(options = {}) {
|
|
660
|
+
const shouldRefresh = options.refresh === true ||
|
|
661
|
+
(options.refresh === undefined && accessToken === null);
|
|
662
|
+
const operation = explicitAdoptionFlight
|
|
663
|
+
? explicitAdoptionFlight.promise
|
|
664
|
+
: refreshFlight
|
|
665
|
+
? refreshFlight.promise
|
|
666
|
+
: shouldRefresh
|
|
667
|
+
? runRefresh(tokenGeneration, currentBaseline())
|
|
668
|
+
: authorityFlight
|
|
669
|
+
? authorityFlight.promise
|
|
670
|
+
: resolveCurrentSession();
|
|
671
|
+
return waitForCaller(operation, options.signal);
|
|
672
|
+
},
|
|
673
|
+
revalidateSession(options = {}) {
|
|
674
|
+
const operation = explicitAdoptionFlight
|
|
675
|
+
? explicitAdoptionFlight.promise
|
|
676
|
+
: refreshFlight
|
|
677
|
+
? refreshFlight.promise
|
|
678
|
+
: authorityFlight
|
|
679
|
+
? authorityFlight.promise
|
|
680
|
+
: runRevalidate(tokenGeneration);
|
|
681
|
+
return waitForCaller(operation, options.signal);
|
|
266
682
|
},
|
|
267
|
-
/**
|
|
268
|
-
* LOCAL-DEV ONLY (CEL-1364) — see the `AuthStore.devLogin` doc comment.
|
|
269
|
-
*
|
|
270
|
-
* Adoption deliberately routes through `store.setAccessToken()` rather than
|
|
271
|
-
* touching `applyToken` / `scheduleRefresh` directly, so there is exactly
|
|
272
|
-
* ONE token-adoption path shared with verify-otp: same identity fetch, same
|
|
273
|
-
* refresh scheduling, same listener fan-out and ordering.
|
|
274
|
-
*/
|
|
275
683
|
async devLogin(email) {
|
|
276
|
-
let
|
|
684
|
+
let response;
|
|
277
685
|
try {
|
|
278
|
-
|
|
686
|
+
response = await fetchAuthRequest(baseUrl, "/test/login", {
|
|
279
687
|
method: "POST",
|
|
280
|
-
// The route also sets the BFF session + refresh cookies the OTP flow
|
|
281
|
-
// sets; `include` is what lets a subsequent `/auth/refresh` work.
|
|
282
688
|
credentials: "include",
|
|
283
689
|
headers: { "Content-Type": "application/json" },
|
|
284
690
|
body: JSON.stringify({ email }),
|
|
@@ -292,8 +698,8 @@ export function createAuthStore(config) {
|
|
|
292
698
|
message: DEV_LOGIN_MESSAGES.network,
|
|
293
699
|
};
|
|
294
700
|
}
|
|
295
|
-
if (!
|
|
296
|
-
if (
|
|
701
|
+
if (!response.ok) {
|
|
702
|
+
if (response.status === 404) {
|
|
297
703
|
return {
|
|
298
704
|
ok: false,
|
|
299
705
|
reason: "test-endpoints-disabled",
|
|
@@ -301,7 +707,7 @@ export function createAuthStore(config) {
|
|
|
301
707
|
message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
|
|
302
708
|
};
|
|
303
709
|
}
|
|
304
|
-
if (
|
|
710
|
+
if (response.status === 429) {
|
|
305
711
|
return {
|
|
306
712
|
ok: false,
|
|
307
713
|
reason: "rate-limited",
|
|
@@ -309,7 +715,7 @@ export function createAuthStore(config) {
|
|
|
309
715
|
message: DEV_LOGIN_MESSAGES["rate-limited"],
|
|
310
716
|
};
|
|
311
717
|
}
|
|
312
|
-
if (
|
|
718
|
+
if (response.status === 403) {
|
|
313
719
|
return {
|
|
314
720
|
ok: false,
|
|
315
721
|
reason: "forbidden",
|
|
@@ -320,39 +726,30 @@ export function createAuthStore(config) {
|
|
|
320
726
|
return {
|
|
321
727
|
ok: false,
|
|
322
728
|
reason: "unexpected",
|
|
323
|
-
status:
|
|
324
|
-
message: `Dev sign-in failed (HTTP ${
|
|
729
|
+
status: response.status,
|
|
730
|
+
message: `Dev sign-in failed (HTTP ${response.status}).`,
|
|
325
731
|
};
|
|
326
732
|
}
|
|
327
|
-
// Built once: three distinct malformed shapes converge on it below.
|
|
328
733
|
const malformed = {
|
|
329
734
|
ok: false,
|
|
330
735
|
reason: "malformed-response",
|
|
331
|
-
status:
|
|
736
|
+
status: response.status,
|
|
332
737
|
message: DEV_LOGIN_MESSAGES["malformed-response"],
|
|
333
738
|
};
|
|
334
|
-
let
|
|
739
|
+
let raw;
|
|
335
740
|
try {
|
|
336
|
-
|
|
741
|
+
raw = await response.json();
|
|
337
742
|
}
|
|
338
743
|
catch {
|
|
339
744
|
return malformed;
|
|
340
745
|
}
|
|
341
|
-
|
|
342
|
-
// literal `null` (or a bare string/number) parses fine, and
|
|
343
|
-
// `extractAccessToken` dereferences its argument — so passing `null`
|
|
344
|
-
// through would THROW out of a function whose result type promises it
|
|
345
|
-
// never does, leaving the DEV button with no error channel at all.
|
|
346
|
-
// Arrays fall through: they are objects, carry no token, and reach the
|
|
347
|
-
// same `malformed` below.
|
|
348
|
-
if (parsed === null || typeof parsed !== "object") {
|
|
746
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
349
747
|
return malformed;
|
|
350
748
|
}
|
|
351
|
-
const json =
|
|
749
|
+
const json = raw;
|
|
352
750
|
const token = extractAccessToken(json);
|
|
353
|
-
if (!token)
|
|
751
|
+
if (!token)
|
|
354
752
|
return malformed;
|
|
355
|
-
}
|
|
356
753
|
const expiresIn = typeof json.expiresIn === "number"
|
|
357
754
|
? json.expiresIn
|
|
358
755
|
: DEFAULT_ACCESS_TOKEN_TTL;
|
|
@@ -365,18 +762,22 @@ export function createAuthStore(config) {
|
|
|
365
762
|
orgId: typeof json.orgId === "string" ? json.orgId : null,
|
|
366
763
|
};
|
|
367
764
|
},
|
|
368
|
-
getUserId()
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
765
|
+
getUserId: () => identity?.id ?? null,
|
|
766
|
+
getOrgId: () => identity?.orgId ?? null,
|
|
767
|
+
getUserType: () => identity?.userType ?? null,
|
|
768
|
+
getEntitlements: () => identity?.entitlements ? [...identity.entitlements] : [],
|
|
769
|
+
getSessionState: () => copySessionState(sessionState),
|
|
770
|
+
onSessionStateChange(listener) {
|
|
771
|
+
sessionStateListeners.add(listener);
|
|
772
|
+
try {
|
|
773
|
+
listener(copySessionState(sessionState));
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Immediate delivery has the same isolation as later notifications.
|
|
777
|
+
}
|
|
778
|
+
return () => {
|
|
779
|
+
sessionStateListeners.delete(listener);
|
|
780
|
+
};
|
|
380
781
|
},
|
|
381
782
|
onOrgChange(listener) {
|
|
382
783
|
orgChangeListeners.add(listener);
|