@cellarnode/auth 0.13.3 → 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 +120 -4
- 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 +564 -173
- 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/dev-sign-in.d.ts +54 -0
- package/dist/react/dev-sign-in.d.ts.map +1 -0
- package/dist/react/dev-sign-in.js +63 -0
- package/dist/react/dev-sign-in.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +11 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/login-form.d.ts.map +1 -1
- package/dist/react/login-form.js +105 -3
- 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 +130 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
package/dist/auth-store.js
CHANGED
|
@@ -1,252 +1,643 @@
|
|
|
1
|
+
import { copyAuthUser, parseAuthUser } from "./auth-user.js";
|
|
1
2
|
import { extractAccessToken } from "./extract-token.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
import { fetchAuthRequest } from "./auth-transport.js";
|
|
4
|
+
const DEFAULT_ACCESS_TOKEN_TTL = 900;
|
|
5
|
+
const DEFAULT_RESOLUTION_TIMEOUT_MS = 10_000;
|
|
6
|
+
const DEV_LOGIN_MESSAGES = {
|
|
7
|
+
"test-endpoints-disabled": "Dev sign-in unavailable: backend test endpoints are disabled. Set ENABLE_TEST_ENDPOINTS=true on the API and restart it.",
|
|
8
|
+
"rate-limited": "Dev sign-in rate limit hit (5/min). Wait a minute and retry.",
|
|
9
|
+
forbidden: "Dev sign-in rejected: the API requires a fixture secret (TEST_FIXTURE_SECRET is set).",
|
|
10
|
+
network: "Dev sign-in could not reach the API. Is the backend running?",
|
|
11
|
+
"malformed-response": "Dev sign-in succeeded but the API returned no access token.",
|
|
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 };
|
|
19
22
|
}
|
|
20
23
|
export function createAuthStore(config) {
|
|
21
|
-
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;
|
|
22
28
|
let accessToken = null;
|
|
23
29
|
let identity = null;
|
|
24
30
|
let refreshTimer = null;
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// yet" vs "the current org is admin/null". Without it, the FIRST admin
|
|
30
|
-
// identity (orgId null) and an admin login right after `clearToken()` would
|
|
31
|
-
// skip `onOrgChange(null)`. Both are reset on logout so a subsequent login
|
|
32
|
-
// re-fires `onOrgChange` even into the same org.
|
|
31
|
+
let identityFlight = null;
|
|
32
|
+
let refreshFlight = null;
|
|
33
|
+
let explicitAdoptionFlight = null;
|
|
34
|
+
let tokenGeneration = 0;
|
|
33
35
|
let previousOrgId = null;
|
|
34
36
|
let hasEmittedOrgId = false;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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" };
|
|
40
46
|
const orgChangeListeners = new Set();
|
|
41
47
|
const accessTokenSetListeners = new Set();
|
|
42
48
|
const logoutListeners = new Set();
|
|
43
|
-
|
|
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) {
|
|
44
58
|
for (const listener of orgChangeListeners) {
|
|
45
59
|
try {
|
|
46
60
|
listener(orgId);
|
|
47
61
|
}
|
|
48
62
|
catch {
|
|
49
|
-
//
|
|
50
|
-
// break event fan-out to other subscribers.
|
|
63
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
51
64
|
}
|
|
65
|
+
if (!shouldContinue())
|
|
66
|
+
break;
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
|
-
function emitAccessTokenSet(token) {
|
|
69
|
+
function emitAccessTokenSet(token, shouldContinue = () => true) {
|
|
55
70
|
for (const listener of accessTokenSetListeners) {
|
|
56
71
|
try {
|
|
57
72
|
listener(token);
|
|
58
73
|
}
|
|
59
74
|
catch {
|
|
60
|
-
//
|
|
75
|
+
// Subscriber failures cannot break other observers or session adoption.
|
|
61
76
|
}
|
|
77
|
+
if (!shouldContinue())
|
|
78
|
+
break;
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
|
-
function emitLogout() {
|
|
81
|
+
function emitLogout(shouldContinue = () => true) {
|
|
65
82
|
for (const listener of logoutListeners) {
|
|
66
83
|
try {
|
|
67
84
|
listener();
|
|
68
85
|
}
|
|
69
86
|
catch {
|
|
70
|
-
//
|
|
87
|
+
// Subscriber failures cannot break other observers or logout.
|
|
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
|
+
}
|
|
71
115
|
}
|
|
72
116
|
}
|
|
117
|
+
finally {
|
|
118
|
+
publishingState = false;
|
|
119
|
+
}
|
|
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;
|
|
73
128
|
}
|
|
74
129
|
function scheduleRefresh(expiresInSeconds) {
|
|
75
130
|
if (refreshTimer)
|
|
76
131
|
clearTimeout(refreshTimer);
|
|
77
132
|
const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
|
|
78
133
|
refreshTimer = setTimeout(() => {
|
|
79
|
-
|
|
134
|
+
void store.resolveSession({ refresh: true });
|
|
80
135
|
}, delay);
|
|
81
136
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
172
|
async function fetchIdentity(token) {
|
|
173
|
+
let result;
|
|
92
174
|
try {
|
|
93
|
-
|
|
175
|
+
result = await fetchResolutionResponse("/auth/me", {
|
|
94
176
|
method: "GET",
|
|
95
177
|
credentials: "include",
|
|
96
178
|
headers: { Authorization: `Bearer ${token}` },
|
|
97
179
|
});
|
|
98
|
-
if (!res.ok)
|
|
99
|
-
return null;
|
|
100
|
-
const user = (await res.json());
|
|
101
|
-
if (!user || typeof user.id !== "string" || user.id.length === 0) {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
return toIdentity(user);
|
|
105
180
|
}
|
|
106
181
|
catch {
|
|
107
|
-
return
|
|
182
|
+
return { status: "unavailable" };
|
|
183
|
+
}
|
|
184
|
+
const { response, raw } = result;
|
|
185
|
+
if (response.status === 401 || response.status === 403) {
|
|
186
|
+
return { status: "unauthorized" };
|
|
108
187
|
}
|
|
188
|
+
if (!response.ok)
|
|
189
|
+
return { status: "unavailable" };
|
|
190
|
+
const user = parseAuthUser(raw);
|
|
191
|
+
return user
|
|
192
|
+
? { status: "ready", user }
|
|
193
|
+
: { status: "unavailable" };
|
|
109
194
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
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" };
|
|
131
250
|
}
|
|
132
|
-
identity = nextIdentity;
|
|
133
|
-
const nextOrgId = nextIdentity?.orgId ?? null;
|
|
134
|
-
// (b) Token-set first — handlers read getOrgId()/getUserId() and must see
|
|
135
|
-
// the just-cached identity.
|
|
136
|
-
emitAccessTokenSet(token);
|
|
137
|
-
// (c) Org-change on the first emission OR an actual transition. The
|
|
138
|
-
// `!hasEmittedOrgId` guard ensures the first identity (incl. an admin
|
|
139
|
-
// orgId of null) always fires once.
|
|
140
251
|
if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
|
|
141
252
|
hasEmittedOrgId = true;
|
|
142
253
|
previousOrgId = nextOrgId;
|
|
143
|
-
emitOrgChange(nextOrgId);
|
|
254
|
+
emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
|
|
255
|
+
}
|
|
256
|
+
if (generation !== tokenGeneration || accessToken !== token) {
|
|
257
|
+
return { status: "superseded" };
|
|
144
258
|
}
|
|
259
|
+
const result = {
|
|
260
|
+
status: "ready",
|
|
261
|
+
token,
|
|
262
|
+
user: copyAuthUser(identity),
|
|
263
|
+
};
|
|
264
|
+
settleExplicitGeneration(generation, result);
|
|
265
|
+
return result;
|
|
145
266
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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);
|
|
166
298
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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;
|
|
181
325
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
188
353
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
});
|
|
192
380
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
381
|
+
catch {
|
|
382
|
+
return markUnavailable(refreshGeneration, accessToken);
|
|
383
|
+
}
|
|
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" };
|
|
199
391
|
}
|
|
200
|
-
|
|
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;
|
|
201
441
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
205
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)));
|
|
206
500
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
hasAccessToken() {
|
|
212
|
-
return accessToken !== null;
|
|
213
|
-
},
|
|
501
|
+
let store;
|
|
502
|
+
store = {
|
|
503
|
+
getAccessToken: () => accessToken,
|
|
504
|
+
hasAccessToken: () => accessToken !== null,
|
|
214
505
|
setAccessToken(token, expiresIn) {
|
|
215
|
-
|
|
216
|
-
scheduleRefresh(expiresIn);
|
|
506
|
+
startExplicitToken(token, expiresIn);
|
|
217
507
|
},
|
|
218
508
|
clearAccessToken() {
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
509
|
+
supersedeExplicitAdoption();
|
|
510
|
+
if (clearCurrentGeneration(tokenGeneration)) {
|
|
511
|
+
const clearedGeneration = tokenGeneration;
|
|
512
|
+
emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
|
|
223
513
|
}
|
|
224
|
-
// Emits unconditionally so consumers can rely on `onLogout` as a
|
|
225
|
-
// canonical logout signal regardless of prior token state.
|
|
226
|
-
emitLogout();
|
|
227
514
|
},
|
|
228
515
|
async ensureAccessToken(forceRefresh = false) {
|
|
229
|
-
if (!forceRefresh &&
|
|
516
|
+
if (!forceRefresh &&
|
|
517
|
+
accessToken &&
|
|
518
|
+
identity &&
|
|
519
|
+
!identityFlight &&
|
|
520
|
+
!refreshFlight &&
|
|
521
|
+
sessionState.status === "ready") {
|
|
230
522
|
return accessToken;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
getUserId() {
|
|
239
|
-
return identity?.userId ?? null;
|
|
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;
|
|
240
530
|
},
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
|
243
542
|
},
|
|
244
|
-
|
|
245
|
-
|
|
543
|
+
async devLogin(email) {
|
|
544
|
+
let response;
|
|
545
|
+
try {
|
|
546
|
+
response = await fetchAuthRequest(baseUrl, "/test/login", {
|
|
547
|
+
method: "POST",
|
|
548
|
+
credentials: "include",
|
|
549
|
+
headers: { "Content-Type": "application/json" },
|
|
550
|
+
body: JSON.stringify({ email }),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
reason: "network",
|
|
557
|
+
status: null,
|
|
558
|
+
message: DEV_LOGIN_MESSAGES.network,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
if (response.status === 404) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
reason: "test-endpoints-disabled",
|
|
566
|
+
status: 404,
|
|
567
|
+
message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (response.status === 429) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
reason: "rate-limited",
|
|
574
|
+
status: 429,
|
|
575
|
+
message: DEV_LOGIN_MESSAGES["rate-limited"],
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (response.status === 403) {
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
reason: "forbidden",
|
|
582
|
+
status: 403,
|
|
583
|
+
message: DEV_LOGIN_MESSAGES.forbidden,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
reason: "unexpected",
|
|
589
|
+
status: response.status,
|
|
590
|
+
message: `Dev sign-in failed (HTTP ${response.status}).`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const malformed = {
|
|
594
|
+
ok: false,
|
|
595
|
+
reason: "malformed-response",
|
|
596
|
+
status: response.status,
|
|
597
|
+
message: DEV_LOGIN_MESSAGES["malformed-response"],
|
|
598
|
+
};
|
|
599
|
+
let raw;
|
|
600
|
+
try {
|
|
601
|
+
raw = await response.json();
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return malformed;
|
|
605
|
+
}
|
|
606
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
607
|
+
return malformed;
|
|
608
|
+
}
|
|
609
|
+
const json = raw;
|
|
610
|
+
const token = extractAccessToken(json);
|
|
611
|
+
if (!token)
|
|
612
|
+
return malformed;
|
|
613
|
+
const expiresIn = typeof json.expiresIn === "number"
|
|
614
|
+
? json.expiresIn
|
|
615
|
+
: DEFAULT_ACCESS_TOKEN_TTL;
|
|
616
|
+
store.setAccessToken(token, expiresIn);
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
accessToken: token,
|
|
620
|
+
expiresIn,
|
|
621
|
+
userId: typeof json.userId === "string" ? json.userId : null,
|
|
622
|
+
orgId: typeof json.orgId === "string" ? json.orgId : null,
|
|
623
|
+
};
|
|
246
624
|
},
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
};
|
|
250
641
|
},
|
|
251
642
|
onOrgChange(listener) {
|
|
252
643
|
orgChangeListeners.add(listener);
|