@svadmin/sso 0.10.0 → 0.11.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/dist/auth-helpers.d.ts +23 -0
- package/dist/auth-helpers.js +97 -0
- package/dist/auth-provider.d.ts +68 -0
- package/dist/auth-provider.js +555 -0
- package/dist/authenticated-fetch.d.ts +9 -0
- package/dist/authenticated-fetch.js +71 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +86 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/presets.d.ts +81 -0
- package/dist/presets.js +165 -0
- package/dist/session-manager.d.ts +50 -0
- package/dist/session-manager.js +566 -0
- package/package.json +11 -11
- package/src/auth-helpers.ts +0 -114
- package/src/auth-provider.ts +0 -485
- package/src/index.ts +0 -34
- package/src/presets.ts +0 -204
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { isTerminalSessionError, SSOAuthError } from './errors.js';
|
|
2
|
+
const AUTO_REFRESH_RETRY_DELAY_MS = 30_000;
|
|
3
|
+
const processRefreshLockTails = new WeakMap();
|
|
4
|
+
function createStorageKeys(storageKey) {
|
|
5
|
+
return {
|
|
6
|
+
tokens: `${storageKey}_tokens`,
|
|
7
|
+
pkceVerifier: `${storageKey}_pkce_verifier`,
|
|
8
|
+
state: `${storageKey}_state`,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function parseSession(raw) {
|
|
12
|
+
if (!raw)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const value = JSON.parse(raw);
|
|
16
|
+
if (typeof value.access_token !== 'string'
|
|
17
|
+
|| !value.access_token
|
|
18
|
+
|| typeof value.token_type !== 'string'
|
|
19
|
+
|| !value.token_type
|
|
20
|
+
|| (value.id_token !== undefined && typeof value.id_token !== 'string')
|
|
21
|
+
|| (value.refresh_token !== undefined && typeof value.refresh_token !== 'string')
|
|
22
|
+
|| (value.expires_at !== undefined
|
|
23
|
+
&& (typeof value.expires_at !== 'number' || !Number.isFinite(value.expires_at)))) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isTerminalRefreshError(error) {
|
|
33
|
+
if (isTerminalSessionError(error))
|
|
34
|
+
return true;
|
|
35
|
+
return error instanceof SSOAuthError
|
|
36
|
+
&& error.statusCode >= 400
|
|
37
|
+
&& error.statusCode < 500
|
|
38
|
+
&& !error.retryable;
|
|
39
|
+
}
|
|
40
|
+
function normalizeTerminalRefreshError(error) {
|
|
41
|
+
if (!(error instanceof SSOAuthError) || isTerminalSessionError(error))
|
|
42
|
+
return error;
|
|
43
|
+
return new SSOAuthError(error.message, error.statusCode, {
|
|
44
|
+
code: 'session_refresh_failed',
|
|
45
|
+
details: error.details,
|
|
46
|
+
body: error.body,
|
|
47
|
+
retryable: false,
|
|
48
|
+
cause: error,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function getBrowserRefreshLock() {
|
|
52
|
+
if (typeof window === 'undefined' || !window.navigator?.locks)
|
|
53
|
+
return undefined;
|
|
54
|
+
return {
|
|
55
|
+
request(name, operation) {
|
|
56
|
+
return window.navigator.locks.request(name, operation);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function getBroadcastChannel(name) {
|
|
61
|
+
if (typeof window === 'undefined' || typeof window.BroadcastChannel !== 'function') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return new window.BroadcastChannel(name);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function hasStorageEvents() {
|
|
72
|
+
return typeof window !== 'undefined'
|
|
73
|
+
&& typeof window.addEventListener === 'function'
|
|
74
|
+
&& typeof window.removeEventListener === 'function';
|
|
75
|
+
}
|
|
76
|
+
function clearStorageValue(storage, key) {
|
|
77
|
+
try {
|
|
78
|
+
storage.removeItem(key);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
catch (removeError) {
|
|
82
|
+
try {
|
|
83
|
+
storage.setItem(key, '');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return removeError;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function createProcessRefreshLock(storage) {
|
|
92
|
+
return {
|
|
93
|
+
async request(name, operation) {
|
|
94
|
+
const tails = processRefreshLockTails.get(storage) ?? new Map();
|
|
95
|
+
processRefreshLockTails.set(storage, tails);
|
|
96
|
+
const previous = tails.get(name) ?? Promise.resolve();
|
|
97
|
+
let release;
|
|
98
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
99
|
+
tails.set(name, current);
|
|
100
|
+
await previous.catch(() => undefined);
|
|
101
|
+
try {
|
|
102
|
+
return await operation();
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
release();
|
|
106
|
+
if (tails.get(name) === current)
|
|
107
|
+
tails.delete(name);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function createUnavailableBrowserRefreshLock() {
|
|
113
|
+
return {
|
|
114
|
+
request() {
|
|
115
|
+
return Promise.reject(new Error('This browser does not provide Web Locks; configure an atomic cross-context refreshLock'));
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function isBrowserRuntime() {
|
|
120
|
+
return typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
121
|
+
}
|
|
122
|
+
export function createSessionManager(options) {
|
|
123
|
+
const keys = createStorageKeys(options.storageKey);
|
|
124
|
+
const legacyKeys = options.legacyStorageKey && options.legacyStorageKey !== options.storageKey
|
|
125
|
+
? createStorageKeys(options.legacyStorageKey)
|
|
126
|
+
: null;
|
|
127
|
+
const refreshLockName = `${options.storageKey}:refresh`;
|
|
128
|
+
const authLockName = `${options.storageKey}:auth`;
|
|
129
|
+
const lock = options.refreshLock
|
|
130
|
+
?? getBrowserRefreshLock()
|
|
131
|
+
?? (isBrowserRuntime()
|
|
132
|
+
? createUnavailableBrowserRefreshLock()
|
|
133
|
+
: createProcessRefreshLock(options.storage));
|
|
134
|
+
const listeners = new Set();
|
|
135
|
+
const channel = getBroadcastChannel(`${options.storageKey}:auth`);
|
|
136
|
+
let refreshTimer = null;
|
|
137
|
+
let refreshingPromise = null;
|
|
138
|
+
let destroyed = false;
|
|
139
|
+
let locallySignedOut = false;
|
|
140
|
+
let authGeneration = 0;
|
|
141
|
+
function hasPendingAuthAttempt() {
|
|
142
|
+
return options.storage.getItem(keys.state) !== null;
|
|
143
|
+
}
|
|
144
|
+
function getSessionSnapshot() {
|
|
145
|
+
const raw = locallySignedOut ? null : options.storage.getItem(keys.tokens);
|
|
146
|
+
return { raw, session: parseSession(raw) };
|
|
147
|
+
}
|
|
148
|
+
function migrateLegacyStorage() {
|
|
149
|
+
if (!legacyKeys || options.storage.getItem(keys.tokens))
|
|
150
|
+
return;
|
|
151
|
+
const legacySession = options.storage.getItem(legacyKeys.tokens);
|
|
152
|
+
if (legacySession && parseSession(legacySession)) {
|
|
153
|
+
options.storage.setItem(keys.tokens, legacySession);
|
|
154
|
+
options.storage.removeItem(legacyKeys.tokens);
|
|
155
|
+
}
|
|
156
|
+
const legacyVerifier = options.storage.getItem(legacyKeys.pkceVerifier);
|
|
157
|
+
if (legacyVerifier && !options.storage.getItem(keys.pkceVerifier)) {
|
|
158
|
+
options.storage.setItem(keys.pkceVerifier, legacyVerifier);
|
|
159
|
+
options.storage.removeItem(legacyKeys.pkceVerifier);
|
|
160
|
+
}
|
|
161
|
+
const legacyState = options.storage.getItem(legacyKeys.state);
|
|
162
|
+
if (legacyState && !options.storage.getItem(keys.state)) {
|
|
163
|
+
options.storage.setItem(keys.state, legacyState);
|
|
164
|
+
options.storage.removeItem(legacyKeys.state);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
migrateLegacyStorage();
|
|
168
|
+
let lastObservedRaw = options.storage.getItem(keys.tokens);
|
|
169
|
+
function getSession() {
|
|
170
|
+
return getSessionSnapshot().session;
|
|
171
|
+
}
|
|
172
|
+
function emit(event, session) {
|
|
173
|
+
if (destroyed)
|
|
174
|
+
return;
|
|
175
|
+
for (const listener of listeners) {
|
|
176
|
+
try {
|
|
177
|
+
const result = listener(event, session);
|
|
178
|
+
if (result && typeof result.then === 'function') {
|
|
179
|
+
void Promise.resolve(result).catch((error) => {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
console.warn(`[svadmin/sso] async auth state listener failed: ${message}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
console.warn(`[svadmin/sso] auth state listener failed: ${message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function broadcast(event, preserveAuthAttempt = false) {
|
|
192
|
+
if (destroyed)
|
|
193
|
+
return;
|
|
194
|
+
try {
|
|
195
|
+
channel?.postMessage({
|
|
196
|
+
storageKey: options.storageKey,
|
|
197
|
+
event,
|
|
198
|
+
...(preserveAuthAttempt ? { preserveAuthAttempt: true } : {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
console.warn(`[svadmin/sso] auth state broadcast failed: ${message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function clearRefreshTimer() {
|
|
207
|
+
if (refreshTimer) {
|
|
208
|
+
clearTimeout(refreshTimer);
|
|
209
|
+
refreshTimer = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function scheduleRefresh(session) {
|
|
213
|
+
clearRefreshTimer();
|
|
214
|
+
if (destroyed
|
|
215
|
+
|| !options.autoRefresh
|
|
216
|
+
|| !session?.refresh_token
|
|
217
|
+
|| session.expires_at === undefined) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const now = Math.floor(Date.now() / 1000);
|
|
221
|
+
const delay = Math.max(0, session.expires_at - now - options.refreshBuffer) * 1000;
|
|
222
|
+
refreshTimer = setTimeout(runScheduledRefresh, delay);
|
|
223
|
+
}
|
|
224
|
+
function runScheduledRefresh() {
|
|
225
|
+
if (refreshTimer)
|
|
226
|
+
clearTimeout(refreshTimer);
|
|
227
|
+
refreshTimer = null;
|
|
228
|
+
void refreshSession().catch((error) => {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
console.warn(`[svadmin/sso] automatic token refresh failed; session retained: ${message}`);
|
|
231
|
+
if (!destroyed
|
|
232
|
+
&& error instanceof SSOAuthError
|
|
233
|
+
&& error.retryable
|
|
234
|
+
&& getSession()?.refresh_token) {
|
|
235
|
+
refreshTimer = setTimeout(runScheduledRefresh, AUTO_REFRESH_RETRY_DELAY_MS);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function saveSession(session, event) {
|
|
240
|
+
const raw = JSON.stringify(session);
|
|
241
|
+
try {
|
|
242
|
+
// 单次 setItem 写入完整 token set,避免轮换只落盘一半。
|
|
243
|
+
options.storage.setItem(keys.tokens, raw);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
clearStorageValue(options.storage, keys.tokens);
|
|
247
|
+
authGeneration += 1;
|
|
248
|
+
locallySignedOut = true;
|
|
249
|
+
lastObservedRaw = null;
|
|
250
|
+
clearRefreshTimer();
|
|
251
|
+
emit('SIGNED_OUT', null);
|
|
252
|
+
broadcast('SIGNED_OUT');
|
|
253
|
+
throw new SSOAuthError('Session could not be persisted', 0, {
|
|
254
|
+
code: 'session_persistence_failed',
|
|
255
|
+
retryable: false,
|
|
256
|
+
cause: error,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (event !== 'TOKEN_REFRESHED')
|
|
260
|
+
authGeneration += 1;
|
|
261
|
+
locallySignedOut = false;
|
|
262
|
+
lastObservedRaw = raw;
|
|
263
|
+
scheduleRefresh(session);
|
|
264
|
+
if (event) {
|
|
265
|
+
emit(event, session);
|
|
266
|
+
broadcast(event);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function clearSession() {
|
|
270
|
+
const failures = [keys.tokens, keys.pkceVerifier, keys.state]
|
|
271
|
+
.map((key) => clearStorageValue(options.storage, key));
|
|
272
|
+
const persistenceFailure = failures.find((error) => error !== null);
|
|
273
|
+
authGeneration += 1;
|
|
274
|
+
locallySignedOut = true;
|
|
275
|
+
lastObservedRaw = null;
|
|
276
|
+
clearRefreshTimer();
|
|
277
|
+
emit('SIGNED_OUT', null);
|
|
278
|
+
broadcast('SIGNED_OUT');
|
|
279
|
+
if (persistenceFailure !== undefined && persistenceFailure !== null) {
|
|
280
|
+
throw new SSOAuthError('Session could not be cleared', 0, {
|
|
281
|
+
code: 'session_persistence_failed',
|
|
282
|
+
retryable: false,
|
|
283
|
+
cause: persistenceFailure,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function clearTokenSession() {
|
|
288
|
+
const persistenceFailure = clearStorageValue(options.storage, keys.tokens);
|
|
289
|
+
// 仅 token 失效不能取消已经持有 PKCE/state 的新认证尝试。
|
|
290
|
+
// 显式 logout 会走 clearSession(),仍会清理完整状态并推进 generation。
|
|
291
|
+
const preservesAuthAttempt = hasPendingAuthAttempt();
|
|
292
|
+
if (!preservesAuthAttempt) {
|
|
293
|
+
authGeneration += 1;
|
|
294
|
+
locallySignedOut = true;
|
|
295
|
+
}
|
|
296
|
+
lastObservedRaw = null;
|
|
297
|
+
clearRefreshTimer();
|
|
298
|
+
if (!preservesAuthAttempt)
|
|
299
|
+
emit('SIGNED_OUT', null);
|
|
300
|
+
broadcast('SIGNED_OUT', preservesAuthAttempt);
|
|
301
|
+
if (persistenceFailure !== null) {
|
|
302
|
+
throw new SSOAuthError('Session could not be cleared', 0, {
|
|
303
|
+
code: 'session_persistence_failed',
|
|
304
|
+
retryable: false,
|
|
305
|
+
cause: persistenceFailure,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function resolveRefreshRace(expectedRaw) {
|
|
310
|
+
const currentRaw = locallySignedOut ? null : options.storage.getItem(keys.tokens);
|
|
311
|
+
if (currentRaw === expectedRaw)
|
|
312
|
+
return { changed: false };
|
|
313
|
+
if (currentRaw === null)
|
|
314
|
+
return { changed: true, session: null };
|
|
315
|
+
const currentSession = parseSession(currentRaw);
|
|
316
|
+
if (!currentSession) {
|
|
317
|
+
clearTokenSession();
|
|
318
|
+
throw new SSOAuthError('Stored session became invalid during token refresh', 0, {
|
|
319
|
+
code: 'session_refresh_failed',
|
|
320
|
+
retryable: false,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
scheduleRefresh(currentSession);
|
|
324
|
+
return { changed: true, session: currentSession };
|
|
325
|
+
}
|
|
326
|
+
function setLoginState(verifier, state) {
|
|
327
|
+
options.storage.setItem(keys.pkceVerifier, verifier);
|
|
328
|
+
options.storage.setItem(keys.state, state);
|
|
329
|
+
authGeneration += 1;
|
|
330
|
+
}
|
|
331
|
+
function beginAuthAttempt() {
|
|
332
|
+
authGeneration += 1;
|
|
333
|
+
}
|
|
334
|
+
function beginSignOut() {
|
|
335
|
+
authGeneration += 1;
|
|
336
|
+
locallySignedOut = true;
|
|
337
|
+
clearRefreshTimer();
|
|
338
|
+
}
|
|
339
|
+
function clearLoginState(expectedState) {
|
|
340
|
+
if (expectedState !== undefined && options.storage.getItem(keys.state) !== expectedState) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
options.storage.removeItem(keys.pkceVerifier);
|
|
344
|
+
options.storage.removeItem(keys.state);
|
|
345
|
+
}
|
|
346
|
+
async function runWithLock(name, operation, createLockError) {
|
|
347
|
+
let operationStarted = false;
|
|
348
|
+
try {
|
|
349
|
+
return await lock.request(name, async () => {
|
|
350
|
+
operationStarted = true;
|
|
351
|
+
return operation();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (operationStarted)
|
|
356
|
+
throw error;
|
|
357
|
+
throw createLockError(error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function runWithRefreshLock(operation) {
|
|
361
|
+
return runWithLock(refreshLockName, operation, (cause) => new SSOAuthError('Token refresh lock could not be acquired', 0, {
|
|
362
|
+
code: 'refresh_lock_failed',
|
|
363
|
+
retryable: true,
|
|
364
|
+
cause,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
function runAuthMutation(operation) {
|
|
368
|
+
return runWithLock(authLockName, operation, (cause) => new SSOAuthError('Authentication coordination lock could not be acquired', 0, {
|
|
369
|
+
code: 'auth_lock_failed',
|
|
370
|
+
retryable: false,
|
|
371
|
+
cause,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
function resolveTerminalRefreshFailure(expectedRaw, error) {
|
|
375
|
+
const race = resolveRefreshRace(expectedRaw);
|
|
376
|
+
if (race.changed && race.session)
|
|
377
|
+
return race.session;
|
|
378
|
+
if (!race.changed)
|
|
379
|
+
clearTokenSession();
|
|
380
|
+
throw normalizeTerminalRefreshError(error);
|
|
381
|
+
}
|
|
382
|
+
function clearTokenSessionIfCurrent(expectedRaw) {
|
|
383
|
+
return runAuthMutation(async () => {
|
|
384
|
+
const race = resolveRefreshRace(expectedRaw);
|
|
385
|
+
if (race.changed)
|
|
386
|
+
return race.session;
|
|
387
|
+
clearTokenSession();
|
|
388
|
+
return null;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async function executeRefresh(initialRaw) {
|
|
392
|
+
return runWithRefreshLock(async () => {
|
|
393
|
+
const lockRace = resolveRefreshRace(initialRaw);
|
|
394
|
+
if (lockRace.changed)
|
|
395
|
+
return lockRace.session;
|
|
396
|
+
const latestSession = parseSession(initialRaw);
|
|
397
|
+
if (!latestSession?.refresh_token)
|
|
398
|
+
return null;
|
|
399
|
+
let refreshedSession;
|
|
400
|
+
try {
|
|
401
|
+
refreshedSession = await options.refresh(latestSession);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (isTerminalRefreshError(error)) {
|
|
405
|
+
return runAuthMutation(async () => resolveTerminalRefreshFailure(initialRaw, error));
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
// 网络请求只持有 refresh 锁;最终 CAS 与写入再短暂持有 auth 锁,
|
|
410
|
+
// 使跨标签页 logout/callback commit 与 token rotation 线性化。
|
|
411
|
+
return runAuthMutation(async () => {
|
|
412
|
+
const writeRace = resolveRefreshRace(initialRaw);
|
|
413
|
+
if (writeRace.changed)
|
|
414
|
+
return writeRace.session;
|
|
415
|
+
saveSession(refreshedSession, 'TOKEN_REFRESHED');
|
|
416
|
+
return refreshedSession;
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function refreshSession() {
|
|
421
|
+
if (refreshingPromise)
|
|
422
|
+
return refreshingPromise;
|
|
423
|
+
const initialRaw = locallySignedOut ? null : options.storage.getItem(keys.tokens);
|
|
424
|
+
const initialSession = parseSession(initialRaw);
|
|
425
|
+
if (!initialRaw || !initialSession?.refresh_token) {
|
|
426
|
+
return Promise.resolve(null);
|
|
427
|
+
}
|
|
428
|
+
refreshingPromise = executeRefresh(initialRaw).finally(() => {
|
|
429
|
+
refreshingPromise = null;
|
|
430
|
+
});
|
|
431
|
+
return refreshingPromise;
|
|
432
|
+
}
|
|
433
|
+
async function getAccessToken(accessOptions = {}) {
|
|
434
|
+
const snapshot = getSessionSnapshot();
|
|
435
|
+
if (!snapshot.raw || !snapshot.session)
|
|
436
|
+
return null;
|
|
437
|
+
const { raw: initialRaw, session } = snapshot;
|
|
438
|
+
const minValiditySeconds = Math.max(0, accessOptions.minValiditySeconds ?? 0);
|
|
439
|
+
const now = Math.floor(Date.now() / 1000);
|
|
440
|
+
const expiresSoon = session.expires_at !== undefined
|
|
441
|
+
&& session.expires_at <= now + minValiditySeconds;
|
|
442
|
+
if (!accessOptions.forceRefresh && !expiresSoon) {
|
|
443
|
+
return session.access_token;
|
|
444
|
+
}
|
|
445
|
+
if (!session.refresh_token) {
|
|
446
|
+
if (!expiresSoon)
|
|
447
|
+
return null;
|
|
448
|
+
const latestSession = await clearTokenSessionIfCurrent(initialRaw);
|
|
449
|
+
if (!latestSession)
|
|
450
|
+
return null;
|
|
451
|
+
const latestExpiresSoon = latestSession.expires_at !== undefined
|
|
452
|
+
&& latestSession.expires_at <= now + minValiditySeconds;
|
|
453
|
+
if (!accessOptions.forceRefresh && !latestExpiresSoon) {
|
|
454
|
+
return latestSession.access_token;
|
|
455
|
+
}
|
|
456
|
+
if (!latestSession.refresh_token)
|
|
457
|
+
return null;
|
|
458
|
+
return (await refreshSession())?.access_token ?? null;
|
|
459
|
+
}
|
|
460
|
+
return (await refreshSession())?.access_token ?? null;
|
|
461
|
+
}
|
|
462
|
+
function syncRemoteChange(event, raw, force = false, preserveAuthAttempt = false) {
|
|
463
|
+
if (!force && raw === lastObservedRaw)
|
|
464
|
+
return;
|
|
465
|
+
if (raw === null) {
|
|
466
|
+
// token-only 失效可与新 callback 并发;共享 state 存在时不能把它误判为 logout。
|
|
467
|
+
const shouldPreserveAuthAttempt = preserveAuthAttempt && hasPendingAuthAttempt();
|
|
468
|
+
if (!shouldPreserveAuthAttempt) {
|
|
469
|
+
authGeneration += 1;
|
|
470
|
+
locallySignedOut = true;
|
|
471
|
+
}
|
|
472
|
+
lastObservedRaw = null;
|
|
473
|
+
clearRefreshTimer();
|
|
474
|
+
if (!shouldPreserveAuthAttempt)
|
|
475
|
+
emit('SIGNED_OUT', null);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const session = parseSession(raw);
|
|
479
|
+
if (!session) {
|
|
480
|
+
syncRemoteChange('SIGNED_OUT', null);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// 一次明确的本地登出只有当前上下文的新登录才能撤销;远端刷新不能复活它。
|
|
484
|
+
if (locallySignedOut)
|
|
485
|
+
return;
|
|
486
|
+
authGeneration += 1;
|
|
487
|
+
lastObservedRaw = raw;
|
|
488
|
+
scheduleRefresh(session);
|
|
489
|
+
emit(event, session);
|
|
490
|
+
}
|
|
491
|
+
function onBroadcastMessage(message) {
|
|
492
|
+
const data = message.data;
|
|
493
|
+
if (!data || data.storageKey !== options.storageKey)
|
|
494
|
+
return;
|
|
495
|
+
const currentRaw = options.storage.getItem(keys.tokens);
|
|
496
|
+
if (data.event === 'SIGNED_OUT') {
|
|
497
|
+
if (parseSession(currentRaw))
|
|
498
|
+
return;
|
|
499
|
+
syncRemoteChange('SIGNED_OUT', null, true, data.preserveAuthAttempt === true);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (data.event === 'TOKEN_REFRESHED' && currentRaw === null)
|
|
503
|
+
return;
|
|
504
|
+
syncRemoteChange(data.event, currentRaw);
|
|
505
|
+
}
|
|
506
|
+
function onStorage(event) {
|
|
507
|
+
if (event.key !== keys.tokens)
|
|
508
|
+
return;
|
|
509
|
+
const remoteSession = parseSession(event.newValue);
|
|
510
|
+
syncRemoteChange(remoteSession ? 'TOKEN_REFRESHED' : 'SIGNED_OUT', remoteSession ? event.newValue : null, !remoteSession, !remoteSession);
|
|
511
|
+
}
|
|
512
|
+
function onVisibilityChange() {
|
|
513
|
+
if (!options.autoRefresh || document.visibilityState !== 'visible')
|
|
514
|
+
return;
|
|
515
|
+
const session = getSession();
|
|
516
|
+
if (!session?.refresh_token || session.expires_at === undefined)
|
|
517
|
+
return;
|
|
518
|
+
const now = Math.floor(Date.now() / 1000);
|
|
519
|
+
if (session.expires_at <= now + options.refreshBuffer) {
|
|
520
|
+
runScheduledRefresh();
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
scheduleRefresh(session);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
channel?.addEventListener('message', onBroadcastMessage);
|
|
527
|
+
if (hasStorageEvents())
|
|
528
|
+
window.addEventListener('storage', onStorage);
|
|
529
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
530
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
531
|
+
}
|
|
532
|
+
scheduleRefresh(parseSession(lastObservedRaw));
|
|
533
|
+
return {
|
|
534
|
+
getSession,
|
|
535
|
+
getAuthGeneration: () => authGeneration,
|
|
536
|
+
saveSession,
|
|
537
|
+
clearSession,
|
|
538
|
+
beginAuthAttempt,
|
|
539
|
+
beginSignOut,
|
|
540
|
+
setLoginState,
|
|
541
|
+
getPKCEVerifier: () => options.storage.getItem(keys.pkceVerifier),
|
|
542
|
+
getLoginState: () => options.storage.getItem(keys.state),
|
|
543
|
+
clearLoginState,
|
|
544
|
+
runAuthMutation,
|
|
545
|
+
refreshSession,
|
|
546
|
+
getAccessToken,
|
|
547
|
+
onAuthStateChange(callback) {
|
|
548
|
+
listeners.add(callback);
|
|
549
|
+
return () => listeners.delete(callback);
|
|
550
|
+
},
|
|
551
|
+
destroy() {
|
|
552
|
+
if (destroyed)
|
|
553
|
+
return;
|
|
554
|
+
destroyed = true;
|
|
555
|
+
clearRefreshTimer();
|
|
556
|
+
listeners.clear();
|
|
557
|
+
channel?.removeEventListener('message', onBroadcastMessage);
|
|
558
|
+
channel?.close();
|
|
559
|
+
if (hasStorageEvents())
|
|
560
|
+
window.removeEventListener('storage', onStorage);
|
|
561
|
+
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
562
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/sso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OIDC/OAuth2 SSO AuthProvider plugin for svadmin — supports Okta, Azure AD, Cognito, Keycloak, Google",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,27 +19,27 @@
|
|
|
19
19
|
"url": "https://github.com/zuohuadong/svadmin",
|
|
20
20
|
"directory": "packages/sso"
|
|
21
21
|
},
|
|
22
|
+
"sideEffects": false,
|
|
22
23
|
"exports": {
|
|
23
24
|
".": {
|
|
24
|
-
"types": "./
|
|
25
|
-
"import": "./
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
26
27
|
}
|
|
27
28
|
},
|
|
28
|
-
"main": "./
|
|
29
|
-
"types": "./
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
30
31
|
"files": [
|
|
31
|
-
"
|
|
32
|
-
"!src/**/*.test.*",
|
|
33
|
-
"!src/**/*.spec.*",
|
|
34
|
-
"!src/**/vitest.config.*",
|
|
35
|
-
"!src/**/setupTest.*"
|
|
32
|
+
"dist"
|
|
36
33
|
],
|
|
37
34
|
"peerDependencies": {
|
|
38
|
-
"@svadmin/core": "^0.
|
|
35
|
+
"@svadmin/core": "^0.32.0"
|
|
39
36
|
},
|
|
40
37
|
"devDependencies": {
|
|
41
38
|
"typescript": "^5.7.0"
|
|
42
39
|
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "bun ../../scripts/build-node-package.ts"
|
|
42
|
+
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@svadmin/core": {
|
|
45
45
|
"optional": true
|