@cellarnode/auth 0.14.0 → 0.15.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 +491 -230
- 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 +67 -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,541 @@ 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", 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
|
-
// yet" vs "the current org is admin/null". Without it, the FIRST admin
|
|
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 explicitAdoptionFlight = null;
|
|
34
|
+
let tokenGeneration = 0;
|
|
62
35
|
let previousOrgId = null;
|
|
63
36
|
let hasEmittedOrgId = false;
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
37
|
+
// Last validated principal/tenant survives transient authority outages. It
|
|
38
|
+
// never powers authority getters; it only prevents raw same-token identity
|
|
39
|
+
// changes from becoming trusted after an unavailable read.
|
|
40
|
+
let continuityBaseline = null;
|
|
41
|
+
// A refreshed credential may be adopted before its identity read succeeds.
|
|
42
|
+
// Preserve its old-principal lineage so a later retry still verifies account
|
|
43
|
+
// continuity while permitting a fresh-token organisation transition.
|
|
44
|
+
let pendingRefreshBaseline = null;
|
|
45
|
+
let sessionState = { status: "unauthorized" };
|
|
69
46
|
const orgChangeListeners = new Set();
|
|
70
47
|
const accessTokenSetListeners = new Set();
|
|
71
48
|
const logoutListeners = new Set();
|
|
72
|
-
|
|
49
|
+
const sessionStateListeners = new Set();
|
|
50
|
+
const stateQueue = [];
|
|
51
|
+
let publishingState = false;
|
|
52
|
+
function settleExplicitGeneration(generation, resolution) {
|
|
53
|
+
if (explicitAdoptionFlight?.generation === generation) {
|
|
54
|
+
explicitAdoptionFlight.settle(resolution);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function emitOrgChange(orgId, shouldContinue = () => true) {
|
|
73
58
|
for (const listener of orgChangeListeners) {
|
|
74
59
|
try {
|
|
75
60
|
listener(orgId);
|
|
76
61
|
}
|
|
77
62
|
catch {
|
|
78
|
-
//
|
|
79
|
-
// break event fan-out to other subscribers.
|
|
63
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
80
64
|
}
|
|
65
|
+
if (!shouldContinue())
|
|
66
|
+
break;
|
|
81
67
|
}
|
|
82
68
|
}
|
|
83
|
-
function emitAccessTokenSet(token) {
|
|
69
|
+
function emitAccessTokenSet(token, shouldContinue = () => true) {
|
|
84
70
|
for (const listener of accessTokenSetListeners) {
|
|
85
71
|
try {
|
|
86
72
|
listener(token);
|
|
87
73
|
}
|
|
88
74
|
catch {
|
|
89
|
-
//
|
|
75
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
90
76
|
}
|
|
77
|
+
if (!shouldContinue())
|
|
78
|
+
break;
|
|
91
79
|
}
|
|
92
80
|
}
|
|
93
|
-
function emitLogout() {
|
|
81
|
+
function emitLogout(shouldContinue = () => true) {
|
|
94
82
|
for (const listener of logoutListeners) {
|
|
95
83
|
try {
|
|
96
84
|
listener();
|
|
97
85
|
}
|
|
98
86
|
catch {
|
|
99
|
-
//
|
|
87
|
+
// Subscriber failures cannot break other observers or logout.
|
|
100
88
|
}
|
|
89
|
+
if (!shouldContinue())
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function publishSessionState(next) {
|
|
94
|
+
stateQueue.push(copySessionState(next));
|
|
95
|
+
if (publishingState)
|
|
96
|
+
return;
|
|
97
|
+
publishingState = true;
|
|
98
|
+
try {
|
|
99
|
+
while (stateQueue.length > 0) {
|
|
100
|
+
const published = stateQueue.shift();
|
|
101
|
+
if (!published)
|
|
102
|
+
continue;
|
|
103
|
+
sessionState = published;
|
|
104
|
+
for (const listener of sessionStateListeners) {
|
|
105
|
+
const queuedBeforeCallback = stateQueue.length;
|
|
106
|
+
try {
|
|
107
|
+
listener(copySessionState(published));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Observers cannot veto or interrupt global session resolution.
|
|
111
|
+
}
|
|
112
|
+
if (stateQueue.length > queuedBeforeCallback)
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
publishingState = false;
|
|
101
119
|
}
|
|
102
120
|
}
|
|
121
|
+
/** Notify consumers before authority or credentials can change. */
|
|
122
|
+
function beginResolving(generation, token) {
|
|
123
|
+
publishSessionState({ status: "resolving", token });
|
|
124
|
+
if (generation !== tokenGeneration)
|
|
125
|
+
return false;
|
|
126
|
+
identity = null;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
103
129
|
function scheduleRefresh(expiresInSeconds) {
|
|
104
130
|
if (refreshTimer)
|
|
105
131
|
clearTimeout(refreshTimer);
|
|
106
132
|
const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
|
|
107
133
|
refreshTimer = setTimeout(() => {
|
|
108
|
-
|
|
134
|
+
void store.resolveSession({ refresh: true });
|
|
109
135
|
}, delay);
|
|
110
136
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
function withResolutionTimeout(operation) {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
controller.abort();
|
|
142
|
+
reject(new Error("Session resolution request timed out"));
|
|
143
|
+
}, requestTimeoutMs);
|
|
144
|
+
let pending;
|
|
145
|
+
try {
|
|
146
|
+
pending = operation(controller.signal);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
reject(error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
void pending.then((value) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve(value);
|
|
156
|
+
}, (error) => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
reject(error);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function fetchResolutionResponse(path, init) {
|
|
163
|
+
return withResolutionTimeout(async (signal) => {
|
|
164
|
+
const response = await fetchAuthRequest(baseUrl, path, {
|
|
165
|
+
...init,
|
|
166
|
+
signal,
|
|
167
|
+
});
|
|
168
|
+
const raw = response.ok ? await response.json() : null;
|
|
169
|
+
return { response, raw };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
120
172
|
async function fetchIdentity(token) {
|
|
173
|
+
let result;
|
|
121
174
|
try {
|
|
122
|
-
|
|
175
|
+
result = await fetchResolutionResponse("/auth/me", {
|
|
123
176
|
method: "GET",
|
|
124
177
|
credentials: "include",
|
|
125
178
|
headers: { Authorization: `Bearer ${token}` },
|
|
126
179
|
});
|
|
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
180
|
}
|
|
135
181
|
catch {
|
|
136
|
-
return
|
|
182
|
+
return { status: "unavailable" };
|
|
183
|
+
}
|
|
184
|
+
const { response, raw } = result;
|
|
185
|
+
if (response.status === 401 || response.status === 403) {
|
|
186
|
+
return { status: "unauthorized" };
|
|
137
187
|
}
|
|
188
|
+
if (!response.ok)
|
|
189
|
+
return { status: "unavailable" };
|
|
190
|
+
const user = parseAuthUser(raw);
|
|
191
|
+
return user
|
|
192
|
+
? { status: "ready", user }
|
|
193
|
+
: { status: "unavailable" };
|
|
138
194
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
195
|
+
function clearCurrentGeneration(generation) {
|
|
196
|
+
if (generation !== tokenGeneration)
|
|
197
|
+
return false;
|
|
198
|
+
tokenGeneration += 1;
|
|
199
|
+
const clearedGeneration = tokenGeneration;
|
|
200
|
+
accessToken = null;
|
|
201
|
+
identity = null;
|
|
202
|
+
continuityBaseline = null;
|
|
203
|
+
pendingRefreshBaseline = null;
|
|
204
|
+
identityFlight = null;
|
|
205
|
+
refreshFlight = null;
|
|
206
|
+
previousOrgId = null;
|
|
207
|
+
hasEmittedOrgId = false;
|
|
208
|
+
if (refreshTimer) {
|
|
209
|
+
clearTimeout(refreshTimer);
|
|
210
|
+
refreshTimer = null;
|
|
211
|
+
}
|
|
212
|
+
publishSessionState({ status: "unauthorized" });
|
|
213
|
+
if (tokenGeneration !== clearedGeneration || accessToken !== null)
|
|
214
|
+
return false;
|
|
215
|
+
emitAccessTokenSet(null, () => tokenGeneration === clearedGeneration && accessToken === null);
|
|
216
|
+
const cleared = tokenGeneration === clearedGeneration && accessToken === null;
|
|
217
|
+
if (cleared) {
|
|
218
|
+
settleExplicitGeneration(generation, { status: "unauthorized" });
|
|
219
|
+
}
|
|
220
|
+
return cleared;
|
|
221
|
+
}
|
|
222
|
+
function markUnavailable(generation, token) {
|
|
223
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
224
|
+
return { status: "superseded" };
|
|
225
|
+
}
|
|
226
|
+
identity = null;
|
|
227
|
+
publishSessionState({ status: "unavailable", token });
|
|
228
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
229
|
+
return { status: "superseded" };
|
|
230
|
+
}
|
|
231
|
+
const result = { status: "unavailable", token };
|
|
232
|
+
settleExplicitGeneration(generation, result);
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
function commitReady(generation, token, user) {
|
|
236
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
237
|
+
return { status: "superseded" };
|
|
238
|
+
}
|
|
239
|
+
identity = copyAuthUser(user);
|
|
240
|
+
const nextOrgId = identity.orgId;
|
|
241
|
+
continuityBaseline = { token, user: copyAuthUser(identity) };
|
|
242
|
+
pendingRefreshBaseline = null;
|
|
243
|
+
publishSessionState({ status: "ready", token, user: identity });
|
|
244
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
245
|
+
return { status: "superseded" };
|
|
246
|
+
}
|
|
247
|
+
emitAccessTokenSet(token, () => generation === tokenGeneration && accessToken === token);
|
|
248
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
249
|
+
return { status: "superseded" };
|
|
160
250
|
}
|
|
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
251
|
if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
|
|
170
252
|
hasEmittedOrgId = true;
|
|
171
253
|
previousOrgId = nextOrgId;
|
|
172
|
-
emitOrgChange(nextOrgId);
|
|
254
|
+
emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
|
|
173
255
|
}
|
|
256
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
257
|
+
return { status: "superseded" };
|
|
258
|
+
}
|
|
259
|
+
const result = {
|
|
260
|
+
status: "ready",
|
|
261
|
+
token,
|
|
262
|
+
user: copyAuthUser(identity),
|
|
263
|
+
};
|
|
264
|
+
settleExplicitGeneration(generation, result);
|
|
265
|
+
return result;
|
|
174
266
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
267
|
+
async function evaluateIdentity(generation, token, baseline, refreshed) {
|
|
268
|
+
const read = await fetchIdentity(token);
|
|
269
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
270
|
+
return { status: "superseded" };
|
|
271
|
+
}
|
|
272
|
+
if (read.status === "unauthorized") {
|
|
273
|
+
return clearCurrentGeneration(generation)
|
|
274
|
+
? { status: "unauthorized" }
|
|
275
|
+
: { status: "superseded" };
|
|
276
|
+
}
|
|
277
|
+
if (read.status === "unavailable") {
|
|
278
|
+
return markUnavailable(generation, token);
|
|
279
|
+
}
|
|
280
|
+
if (baseline && read.user.id !== baseline.user.id) {
|
|
281
|
+
if (!refreshed)
|
|
282
|
+
return runRefresh(generation, baseline);
|
|
283
|
+
return clearCurrentGeneration(generation)
|
|
284
|
+
? { status: "unauthorized" }
|
|
285
|
+
: { status: "superseded" };
|
|
286
|
+
}
|
|
287
|
+
if (baseline && read.user.orgId !== baseline.user.orgId) {
|
|
288
|
+
if (!refreshed) {
|
|
289
|
+
// Same-token raw membership changes are not authority. Rotate once,
|
|
290
|
+
// then validate fresh credentials before adopting the transition.
|
|
291
|
+
return runRefresh(generation, baseline);
|
|
292
|
+
}
|
|
293
|
+
if (token === baseline.token) {
|
|
294
|
+
return markUnavailable(generation, token);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return commitReady(generation, token, read.user);
|
|
195
298
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
299
|
+
function startIdentityFlight(generation, token, baseline, refreshed, notifyResolving) {
|
|
300
|
+
if (identityFlight?.generation === generation) {
|
|
301
|
+
return identityFlight.promise;
|
|
302
|
+
}
|
|
303
|
+
let settle;
|
|
304
|
+
const promise = new Promise((resolve) => {
|
|
305
|
+
settle = resolve;
|
|
306
|
+
});
|
|
307
|
+
const flight = { generation, promise };
|
|
308
|
+
identityFlight = flight;
|
|
309
|
+
if (notifyResolving && !beginResolving(generation, token)) {
|
|
310
|
+
if (identityFlight === flight)
|
|
311
|
+
identityFlight = null;
|
|
312
|
+
settle({ status: "superseded" });
|
|
313
|
+
return promise;
|
|
314
|
+
}
|
|
315
|
+
void evaluateIdentity(generation, token, baseline, refreshed).then((result) => {
|
|
316
|
+
if (identityFlight === flight)
|
|
317
|
+
identityFlight = null;
|
|
318
|
+
settle(result);
|
|
319
|
+
}, () => {
|
|
320
|
+
if (identityFlight === flight)
|
|
321
|
+
identityFlight = null;
|
|
322
|
+
settle(markUnavailable(generation, token));
|
|
323
|
+
});
|
|
324
|
+
return promise;
|
|
210
325
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
326
|
+
function currentBaseline() {
|
|
327
|
+
const baseline = pendingRefreshBaseline ?? continuityBaseline;
|
|
328
|
+
return baseline
|
|
329
|
+
? { token: baseline.token, user: copyAuthUser(baseline.user) }
|
|
330
|
+
: null;
|
|
331
|
+
}
|
|
332
|
+
function runRefresh(generation, baseline) {
|
|
333
|
+
if (refreshFlight)
|
|
334
|
+
return refreshFlight.promise;
|
|
335
|
+
if (generation !== tokenGeneration) {
|
|
336
|
+
return Promise.resolve({ status: "superseded" });
|
|
337
|
+
}
|
|
338
|
+
if (!baseline && identityFlight?.generation === generation) {
|
|
339
|
+
const pendingIdentity = identityFlight.promise;
|
|
340
|
+
return pendingIdentity.then((result) => {
|
|
341
|
+
if (result.status === "ready") {
|
|
342
|
+
return runRefresh(tokenGeneration, {
|
|
343
|
+
token: result.token,
|
|
344
|
+
user: copyAuthUser(result.user),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (result.status === "unavailable" &&
|
|
348
|
+
generation === tokenGeneration &&
|
|
349
|
+
accessToken !== null) {
|
|
350
|
+
return runRefresh(tokenGeneration, null);
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
217
353
|
});
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
354
|
+
}
|
|
355
|
+
let settle;
|
|
356
|
+
const promise = new Promise((resolve) => {
|
|
357
|
+
settle = resolve;
|
|
358
|
+
});
|
|
359
|
+
const flight = { generation, promise };
|
|
360
|
+
refreshFlight = flight;
|
|
361
|
+
if (!beginResolving(generation, accessToken)) {
|
|
362
|
+
if (refreshFlight === flight)
|
|
363
|
+
refreshFlight = null;
|
|
364
|
+
settle({ status: "superseded" });
|
|
365
|
+
return promise;
|
|
366
|
+
}
|
|
367
|
+
// Refresh owns a new operation generation before transport begins. Any
|
|
368
|
+
// older identity read can no longer publish authority while refresh waits.
|
|
369
|
+
tokenGeneration += 1;
|
|
370
|
+
const refreshGeneration = tokenGeneration;
|
|
371
|
+
flight.generation = refreshGeneration;
|
|
372
|
+
void (async () => {
|
|
373
|
+
let result;
|
|
374
|
+
try {
|
|
375
|
+
result = await fetchResolutionResponse(refreshPath, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
credentials: "include",
|
|
378
|
+
headers: { "Content-Type": "application/json" },
|
|
379
|
+
});
|
|
221
380
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (token) {
|
|
225
|
-
const expiresIn = typeof json.expiresIn === "number" ? json.expiresIn : 900;
|
|
226
|
-
applyToken(token);
|
|
227
|
-
scheduleRefresh(expiresIn);
|
|
381
|
+
catch {
|
|
382
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
228
383
|
}
|
|
229
|
-
|
|
384
|
+
const { response, raw } = result;
|
|
385
|
+
if (refreshGeneration !== tokenGeneration)
|
|
386
|
+
return { status: "superseded" };
|
|
387
|
+
if (response.status === 401 || response.status === 403) {
|
|
388
|
+
return clearCurrentGeneration(refreshGeneration)
|
|
389
|
+
? { status: "unauthorized" }
|
|
390
|
+
: { status: "superseded" };
|
|
391
|
+
}
|
|
392
|
+
if (!response.ok)
|
|
393
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
394
|
+
if (refreshGeneration !== tokenGeneration)
|
|
395
|
+
return { status: "superseded" };
|
|
396
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
397
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
398
|
+
}
|
|
399
|
+
const json = raw;
|
|
400
|
+
const nextToken = extractAccessToken(json);
|
|
401
|
+
if (!nextToken)
|
|
402
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
403
|
+
const expiresIn = typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn)
|
|
404
|
+
? json.expiresIn
|
|
405
|
+
: DEFAULT_ACCESS_TOKEN_TTL;
|
|
406
|
+
tokenGeneration += 1;
|
|
407
|
+
const nextGeneration = tokenGeneration;
|
|
408
|
+
// Manual/timer callers arriving while refreshed identity still resolves
|
|
409
|
+
// join this same end-to-end refresh operation.
|
|
410
|
+
flight.generation = nextGeneration;
|
|
411
|
+
accessToken = nextToken;
|
|
412
|
+
identity = null;
|
|
413
|
+
pendingRefreshBaseline = baseline
|
|
414
|
+
? { token: baseline.token, user: copyAuthUser(baseline.user) }
|
|
415
|
+
: null;
|
|
416
|
+
publishSessionState({ status: "resolving", token: nextToken });
|
|
417
|
+
if (nextGeneration !== tokenGeneration ||
|
|
418
|
+
accessToken !== nextToken) {
|
|
419
|
+
return { status: "superseded" };
|
|
420
|
+
}
|
|
421
|
+
scheduleRefresh(expiresIn);
|
|
422
|
+
return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
|
|
423
|
+
})().then((result) => {
|
|
424
|
+
if (refreshFlight === flight)
|
|
425
|
+
refreshFlight = null;
|
|
426
|
+
settle(result);
|
|
427
|
+
}, () => {
|
|
428
|
+
if (refreshFlight === flight)
|
|
429
|
+
refreshFlight = null;
|
|
430
|
+
settle(markUnavailable(refreshGeneration, accessToken));
|
|
431
|
+
});
|
|
432
|
+
return promise;
|
|
433
|
+
}
|
|
434
|
+
function resolveCurrentSession() {
|
|
435
|
+
const token = accessToken;
|
|
436
|
+
const generation = tokenGeneration;
|
|
437
|
+
if (!token)
|
|
438
|
+
return Promise.resolve({ status: "unauthorized" });
|
|
439
|
+
if (identityFlight?.generation === generation) {
|
|
440
|
+
return identityFlight.promise;
|
|
230
441
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
442
|
+
const baseline = currentBaseline();
|
|
443
|
+
const refreshed = pendingRefreshBaseline !== null;
|
|
444
|
+
return startIdentityFlight(generation, token, baseline, refreshed, true);
|
|
445
|
+
}
|
|
446
|
+
function waitForCaller(promise, signal) {
|
|
447
|
+
if (!signal)
|
|
448
|
+
return promise.then(copyResolution);
|
|
449
|
+
if (signal.aborted)
|
|
450
|
+
return Promise.resolve({ status: "superseded" });
|
|
451
|
+
return new Promise((resolve) => {
|
|
452
|
+
const onAbort = () => resolve({ status: "superseded" });
|
|
453
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
454
|
+
void promise.then((result) => {
|
|
455
|
+
signal.removeEventListener("abort", onAbort);
|
|
456
|
+
resolve(copyResolution(result));
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
function supersedeExplicitAdoption() {
|
|
461
|
+
const flight = explicitAdoptionFlight;
|
|
462
|
+
if (!flight)
|
|
463
|
+
return;
|
|
464
|
+
explicitAdoptionFlight = null;
|
|
465
|
+
flight.settle({ status: "superseded" });
|
|
466
|
+
}
|
|
467
|
+
function startExplicitToken(token, expiresIn) {
|
|
468
|
+
tokenGeneration += 1;
|
|
469
|
+
const generation = tokenGeneration;
|
|
470
|
+
supersedeExplicitAdoption();
|
|
471
|
+
refreshFlight = null;
|
|
472
|
+
let settled = false;
|
|
473
|
+
let resolveFlight;
|
|
474
|
+
const promise = new Promise((resolve) => {
|
|
475
|
+
resolveFlight = resolve;
|
|
476
|
+
});
|
|
477
|
+
const flight = {
|
|
478
|
+
generation,
|
|
479
|
+
promise,
|
|
480
|
+
settle(resolution) {
|
|
481
|
+
if (settled)
|
|
482
|
+
return;
|
|
483
|
+
settled = true;
|
|
484
|
+
if (explicitAdoptionFlight === flight)
|
|
485
|
+
explicitAdoptionFlight = null;
|
|
486
|
+
resolveFlight(resolution);
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
explicitAdoptionFlight = flight;
|
|
490
|
+
if (!beginResolving(generation, token)) {
|
|
491
|
+
flight.settle({ status: "superseded" });
|
|
492
|
+
return;
|
|
234
493
|
}
|
|
494
|
+
accessToken = token;
|
|
495
|
+
identity = null;
|
|
496
|
+
continuityBaseline = null;
|
|
497
|
+
pendingRefreshBaseline = null;
|
|
498
|
+
scheduleRefresh(expiresIn);
|
|
499
|
+
void startIdentityFlight(generation, token, null, false, false).then(flight.settle, () => flight.settle(markUnavailable(generation, token)));
|
|
235
500
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
hasAccessToken() {
|
|
241
|
-
return accessToken !== null;
|
|
242
|
-
},
|
|
501
|
+
let store;
|
|
502
|
+
store = {
|
|
503
|
+
getAccessToken: () => accessToken,
|
|
504
|
+
hasAccessToken: () => accessToken !== null,
|
|
243
505
|
setAccessToken(token, expiresIn) {
|
|
244
|
-
|
|
245
|
-
scheduleRefresh(expiresIn);
|
|
506
|
+
startExplicitToken(token, expiresIn);
|
|
246
507
|
},
|
|
247
508
|
clearAccessToken() {
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
509
|
+
supersedeExplicitAdoption();
|
|
510
|
+
if (clearCurrentGeneration(tokenGeneration)) {
|
|
511
|
+
const clearedGeneration = tokenGeneration;
|
|
512
|
+
emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
|
|
252
513
|
}
|
|
253
|
-
// Emits unconditionally so consumers can rely on `onLogout` as a
|
|
254
|
-
// canonical logout signal regardless of prior token state.
|
|
255
|
-
emitLogout();
|
|
256
514
|
},
|
|
257
515
|
async ensureAccessToken(forceRefresh = false) {
|
|
258
|
-
if (!forceRefresh &&
|
|
516
|
+
if (!forceRefresh &&
|
|
517
|
+
accessToken &&
|
|
518
|
+
identity &&
|
|
519
|
+
!identityFlight &&
|
|
520
|
+
!refreshFlight &&
|
|
521
|
+
sessionState.status === "ready") {
|
|
259
522
|
return accessToken;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
523
|
+
}
|
|
524
|
+
const result = forceRefresh
|
|
525
|
+
? await store.resolveSession({ refresh: true })
|
|
526
|
+
: await store.resolveSession();
|
|
527
|
+
return result.status === "ready" || result.status === "unavailable"
|
|
528
|
+
? result.token
|
|
529
|
+
: null;
|
|
530
|
+
},
|
|
531
|
+
resolveSession(options = {}) {
|
|
532
|
+
const shouldRefresh = options.refresh === true ||
|
|
533
|
+
(options.refresh === undefined && accessToken === null);
|
|
534
|
+
const operation = explicitAdoptionFlight
|
|
535
|
+
? explicitAdoptionFlight.promise
|
|
536
|
+
: refreshFlight
|
|
537
|
+
? refreshFlight.promise
|
|
538
|
+
: shouldRefresh
|
|
539
|
+
? runRefresh(tokenGeneration, currentBaseline())
|
|
540
|
+
: resolveCurrentSession();
|
|
541
|
+
return waitForCaller(operation, options.signal);
|
|
266
542
|
},
|
|
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
543
|
async devLogin(email) {
|
|
276
|
-
let
|
|
544
|
+
let response;
|
|
277
545
|
try {
|
|
278
|
-
|
|
546
|
+
response = await fetchAuthRequest(baseUrl, "/test/login", {
|
|
279
547
|
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
548
|
credentials: "include",
|
|
283
549
|
headers: { "Content-Type": "application/json" },
|
|
284
550
|
body: JSON.stringify({ email }),
|
|
@@ -292,8 +558,8 @@ export function createAuthStore(config) {
|
|
|
292
558
|
message: DEV_LOGIN_MESSAGES.network,
|
|
293
559
|
};
|
|
294
560
|
}
|
|
295
|
-
if (!
|
|
296
|
-
if (
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
if (response.status === 404) {
|
|
297
563
|
return {
|
|
298
564
|
ok: false,
|
|
299
565
|
reason: "test-endpoints-disabled",
|
|
@@ -301,7 +567,7 @@ export function createAuthStore(config) {
|
|
|
301
567
|
message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
|
|
302
568
|
};
|
|
303
569
|
}
|
|
304
|
-
if (
|
|
570
|
+
if (response.status === 429) {
|
|
305
571
|
return {
|
|
306
572
|
ok: false,
|
|
307
573
|
reason: "rate-limited",
|
|
@@ -309,7 +575,7 @@ export function createAuthStore(config) {
|
|
|
309
575
|
message: DEV_LOGIN_MESSAGES["rate-limited"],
|
|
310
576
|
};
|
|
311
577
|
}
|
|
312
|
-
if (
|
|
578
|
+
if (response.status === 403) {
|
|
313
579
|
return {
|
|
314
580
|
ok: false,
|
|
315
581
|
reason: "forbidden",
|
|
@@ -320,39 +586,30 @@ export function createAuthStore(config) {
|
|
|
320
586
|
return {
|
|
321
587
|
ok: false,
|
|
322
588
|
reason: "unexpected",
|
|
323
|
-
status:
|
|
324
|
-
message: `Dev sign-in failed (HTTP ${
|
|
589
|
+
status: response.status,
|
|
590
|
+
message: `Dev sign-in failed (HTTP ${response.status}).`,
|
|
325
591
|
};
|
|
326
592
|
}
|
|
327
|
-
// Built once: three distinct malformed shapes converge on it below.
|
|
328
593
|
const malformed = {
|
|
329
594
|
ok: false,
|
|
330
595
|
reason: "malformed-response",
|
|
331
|
-
status:
|
|
596
|
+
status: response.status,
|
|
332
597
|
message: DEV_LOGIN_MESSAGES["malformed-response"],
|
|
333
598
|
};
|
|
334
|
-
let
|
|
599
|
+
let raw;
|
|
335
600
|
try {
|
|
336
|
-
|
|
601
|
+
raw = await response.json();
|
|
337
602
|
}
|
|
338
603
|
catch {
|
|
339
604
|
return malformed;
|
|
340
605
|
}
|
|
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") {
|
|
606
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
349
607
|
return malformed;
|
|
350
608
|
}
|
|
351
|
-
const json =
|
|
609
|
+
const json = raw;
|
|
352
610
|
const token = extractAccessToken(json);
|
|
353
|
-
if (!token)
|
|
611
|
+
if (!token)
|
|
354
612
|
return malformed;
|
|
355
|
-
}
|
|
356
613
|
const expiresIn = typeof json.expiresIn === "number"
|
|
357
614
|
? json.expiresIn
|
|
358
615
|
: DEFAULT_ACCESS_TOKEN_TTL;
|
|
@@ -365,18 +622,22 @@ export function createAuthStore(config) {
|
|
|
365
622
|
orgId: typeof json.orgId === "string" ? json.orgId : null,
|
|
366
623
|
};
|
|
367
624
|
},
|
|
368
|
-
getUserId()
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
625
|
+
getUserId: () => identity?.id ?? null,
|
|
626
|
+
getOrgId: () => identity?.orgId ?? null,
|
|
627
|
+
getUserType: () => identity?.userType ?? null,
|
|
628
|
+
getEntitlements: () => identity?.entitlements ? [...identity.entitlements] : [],
|
|
629
|
+
getSessionState: () => copySessionState(sessionState),
|
|
630
|
+
onSessionStateChange(listener) {
|
|
631
|
+
sessionStateListeners.add(listener);
|
|
632
|
+
try {
|
|
633
|
+
listener(copySessionState(sessionState));
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// Immediate delivery has the same isolation as later notifications.
|
|
637
|
+
}
|
|
638
|
+
return () => {
|
|
639
|
+
sessionStateListeners.delete(listener);
|
|
640
|
+
};
|
|
380
641
|
},
|
|
381
642
|
onOrgChange(listener) {
|
|
382
643
|
orgChangeListeners.add(listener);
|