@svadmin/sso 0.9.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.
@@ -0,0 +1,555 @@
1
+ /**
2
+ * @svadmin/sso — OIDC/OAuth2 SSO AuthProvider for svadmin.
3
+ *
4
+ * Uses the Authorization Code Flow with PKCE and supports rotation-safe
5
+ * refresh-token handling for browser applications.
6
+ */
7
+ import { createAuthenticatedFetch as buildAuthenticatedFetch } from './authenticated-fetch.js';
8
+ import { createSSOAuthNetworkError, createSSOAuthResponseError, isTerminalSessionError, SSOAuthError, } from './errors.js';
9
+ import { createSessionManager, } from './session-manager.js';
10
+ const DEFAULT_STORAGE_KEY = 'svadmin_sso';
11
+ function decodeJwtPayload(token) {
12
+ if (!token)
13
+ return null;
14
+ const parts = token.split('.');
15
+ if (parts.length < 2)
16
+ return null;
17
+ const payloadStr = parts[1];
18
+ const padded = payloadStr + '='.repeat((4 - (payloadStr.length % 4)) % 4);
19
+ const binString = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
20
+ const bytes = Uint8Array.from(binString, (value) => value.codePointAt(0) ?? 0);
21
+ return JSON.parse(new TextDecoder().decode(bytes));
22
+ }
23
+ function getAccessTokenExpiry(data, accessToken) {
24
+ if (typeof data.expires_in === 'number' && Number.isFinite(data.expires_in)) {
25
+ return Math.floor(Date.now() / 1000) + data.expires_in;
26
+ }
27
+ try {
28
+ const payload = decodeJwtPayload(accessToken);
29
+ return typeof payload?.exp === 'number' ? payload.exp : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ function createInvalidTokenResponseError(fallbackMessage, field, body) {
36
+ return new SSOAuthError(`${fallbackMessage}: invalid ${field}`, 502, {
37
+ code: 'invalid_token_response',
38
+ body,
39
+ retryable: true,
40
+ });
41
+ }
42
+ function readTokenResponse(body, fallbackMessage) {
43
+ if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
44
+ return body;
45
+ }
46
+ throw createInvalidTokenResponseError(fallbackMessage, 'response body', body);
47
+ }
48
+ function requireTokenString(response, field, fallbackMessage) {
49
+ const token = response[field];
50
+ if (typeof token === 'string' && token.length > 0)
51
+ return token;
52
+ throw createInvalidTokenResponseError(fallbackMessage, field, response);
53
+ }
54
+ function readOptionalTokenString(response, field, fallback, fallbackMessage) {
55
+ return response[field] === undefined
56
+ ? fallback
57
+ : requireTokenString(response, field, fallbackMessage);
58
+ }
59
+ function normalizeTokenResponse(body, defaults, fallbackMessage) {
60
+ const response = readTokenResponse(body, fallbackMessage);
61
+ const accessToken = requireTokenString(response, 'access_token', fallbackMessage);
62
+ return {
63
+ access_token: accessToken,
64
+ id_token: typeof response.id_token === 'string' ? response.id_token : defaults.idToken,
65
+ refresh_token: readOptionalTokenString(response, 'refresh_token', defaults.refreshToken, fallbackMessage),
66
+ expires_at: getAccessTokenExpiry(response, accessToken),
67
+ token_type: readOptionalTokenString(response, 'token_type', defaults.tokenType, fallbackMessage) ?? defaults.tokenType,
68
+ };
69
+ }
70
+ function generateRandomString(length) {
71
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
72
+ const values = crypto.getRandomValues(new Uint8Array(length));
73
+ return Array.from(values, (value) => chars[value % chars.length]).join('');
74
+ }
75
+ async function createPKCEChallenge() {
76
+ const verifier = generateRandomString(64);
77
+ const hashed = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
78
+ const bytes = new Uint8Array(hashed);
79
+ let binary = '';
80
+ for (const byte of bytes)
81
+ binary += String.fromCharCode(byte);
82
+ const challenge = btoa(binary)
83
+ .replace(/\+/g, '-')
84
+ .replace(/\//g, '_')
85
+ .replace(/=+$/, '');
86
+ return { verifier, challenge };
87
+ }
88
+ function createMemoryStorage() {
89
+ const values = new Map();
90
+ return {
91
+ getItem: (key) => values.get(key) ?? null,
92
+ setItem: (key, value) => { values.set(key, value); },
93
+ removeItem: (key) => { values.delete(key); },
94
+ };
95
+ }
96
+ function storageProbe(storage) {
97
+ const probeKey = `svadmin_sso_probe_${Math.random().toString(36).slice(2)}`;
98
+ try {
99
+ storage.setItem(probeKey, '1');
100
+ storage.removeItem(probeKey);
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ function unavailableStorage() {
108
+ return {
109
+ getItem: () => null,
110
+ setItem: () => {
111
+ throw new SSOAuthError('Browser storage is unavailable', 0, {
112
+ code: 'storage_unavailable',
113
+ retryable: false,
114
+ });
115
+ },
116
+ removeItem: () => undefined,
117
+ };
118
+ }
119
+ function safeBrowserStorage(readStorage) {
120
+ try {
121
+ const storage = readStorage();
122
+ return storageProbe(storage) ? storage : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ function getStorage(config) {
129
+ if (config.storage && typeof config.storage === 'object')
130
+ return config.storage;
131
+ if (typeof window === 'undefined')
132
+ return createMemoryStorage();
133
+ const requestedStorage = config.storage === 'session'
134
+ ? safeBrowserStorage(() => window.sessionStorage)
135
+ : safeBrowserStorage(() => window.localStorage)
136
+ ?? safeBrowserStorage(() => window.sessionStorage);
137
+ if (!requestedStorage)
138
+ return unavailableStorage();
139
+ return {
140
+ getItem: (key) => requestedStorage.getItem(key),
141
+ setItem: (key, value) => { requestedStorage.setItem(key, value); },
142
+ removeItem: (key) => { requestedStorage.removeItem(key); },
143
+ };
144
+ }
145
+ function getErrorResult(error) {
146
+ if (error instanceof SSOAuthError) {
147
+ return { message: error.message, name: error.code ?? error.name };
148
+ }
149
+ if (error instanceof Error)
150
+ return { message: error.message, name: error.name };
151
+ return { message: String(error) };
152
+ }
153
+ function getErrorStatus(error) {
154
+ if (typeof error !== 'object' || error === null)
155
+ return undefined;
156
+ if ('statusCode' in error && typeof error.statusCode === 'number')
157
+ return error.statusCode;
158
+ if ('status' in error && typeof error.status === 'number')
159
+ return error.status;
160
+ return undefined;
161
+ }
162
+ /** Create an OIDC/OAuth2 SSO AuthProvider. */
163
+ export function createSSOAuthProvider(config) {
164
+ const scopes = config.scopes ?? ['openid', 'profile', 'email'];
165
+ const refreshBuffer = Math.max(0, config.refreshBuffer ?? 60);
166
+ const storage = getStorage(config);
167
+ const storageKey = config.storageKey
168
+ ?? `${DEFAULT_STORAGE_KEY}:${encodeURIComponent(config.issuer)}:${encodeURIComponent(config.clientId)}`;
169
+ let oidcConfig = null;
170
+ function getFetcher() {
171
+ const fetcher = config.fetcher ?? globalThis.fetch;
172
+ if (typeof fetcher !== 'function') {
173
+ throw new SSOAuthError('No fetch implementation found', 0, {
174
+ code: 'fetch_unavailable',
175
+ retryable: false,
176
+ });
177
+ }
178
+ return fetcher.bind(globalThis);
179
+ }
180
+ async function discover() {
181
+ if (oidcConfig)
182
+ return oidcConfig;
183
+ if (config.manualEndpoints) {
184
+ oidcConfig = config.manualEndpoints;
185
+ return oidcConfig;
186
+ }
187
+ const url = `${config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
188
+ let response;
189
+ try {
190
+ response = await getFetcher()(url);
191
+ }
192
+ catch (error) {
193
+ throw createSSOAuthNetworkError(error, 'OIDC discovery failed');
194
+ }
195
+ if (!response.ok)
196
+ throw await createSSOAuthResponseError(response, 'OIDC discovery failed');
197
+ let discovered;
198
+ try {
199
+ discovered = await response.json();
200
+ }
201
+ catch (error) {
202
+ throw new SSOAuthError('OIDC discovery returned invalid JSON', 502, {
203
+ code: 'invalid_discovery_document',
204
+ retryable: true,
205
+ cause: error,
206
+ });
207
+ }
208
+ if (typeof discovered.authorization_endpoint !== 'string'
209
+ || typeof discovered.token_endpoint !== 'string'
210
+ || typeof discovered.userinfo_endpoint !== 'string') {
211
+ throw new SSOAuthError('OIDC discovery returned incomplete endpoints', 502, {
212
+ code: 'invalid_discovery_document',
213
+ body: discovered,
214
+ });
215
+ }
216
+ oidcConfig = discovered;
217
+ return oidcConfig;
218
+ }
219
+ async function requestToken(endpoint, body, fallbackMessage) {
220
+ let response;
221
+ try {
222
+ response = await getFetcher()(endpoint, {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
225
+ body,
226
+ });
227
+ }
228
+ catch (error) {
229
+ throw createSSOAuthNetworkError(error, fallbackMessage);
230
+ }
231
+ if (!response.ok)
232
+ throw await createSSOAuthResponseError(response, fallbackMessage);
233
+ try {
234
+ return await response.json();
235
+ }
236
+ catch (error) {
237
+ throw new SSOAuthError(`${fallbackMessage}: invalid JSON response`, 502, {
238
+ code: 'invalid_token_response',
239
+ retryable: true,
240
+ cause: error,
241
+ });
242
+ }
243
+ }
244
+ async function performRefresh(current) {
245
+ if (!current.refresh_token) {
246
+ throw new SSOAuthError('Refresh token is unavailable', 401, {
247
+ code: 'session_not_found',
248
+ });
249
+ }
250
+ const endpoints = await discover();
251
+ const response = await requestToken(endpoints.token_endpoint, new URLSearchParams({
252
+ grant_type: 'refresh_token',
253
+ client_id: config.clientId,
254
+ refresh_token: current.refresh_token,
255
+ }), 'Token refresh failed');
256
+ return normalizeTokenResponse(response, {
257
+ idToken: current.id_token,
258
+ refreshToken: current.refresh_token,
259
+ tokenType: current.token_type,
260
+ }, 'Token refresh failed');
261
+ }
262
+ const sessions = createSessionManager({
263
+ storage,
264
+ storageKey,
265
+ autoRefresh: config.autoRefresh ?? true,
266
+ refreshBuffer,
267
+ refreshLock: config.refreshLock,
268
+ refresh: performRefresh,
269
+ legacyStorageKey: config.legacyStorageKey,
270
+ });
271
+ function assertAuthorizationExchangeCurrent(authGeneration, expectedState) {
272
+ if (sessions.getAuthGeneration() === authGeneration
273
+ && sessions.getLoginState() === expectedState)
274
+ return;
275
+ throw new SSOAuthError('Authorization exchange was cancelled by a newer session action', 409, {
276
+ code: 'authorization_exchange_cancelled',
277
+ retryable: false,
278
+ });
279
+ }
280
+ async function clearOwnedLoginState(expectedState) {
281
+ try {
282
+ await sessions.runAuthMutation(async () => {
283
+ sessions.clearLoginState(expectedState);
284
+ });
285
+ }
286
+ catch (error) {
287
+ if (!(error instanceof SSOAuthError) || error.code !== 'auth_lock_failed')
288
+ throw error;
289
+ sessions.clearLoginState(expectedState);
290
+ }
291
+ }
292
+ async function exchangeCode(code, expectedState) {
293
+ const authGeneration = sessions.getAuthGeneration();
294
+ const verifier = sessions.getPKCEVerifier();
295
+ const endpoints = await discover();
296
+ assertAuthorizationExchangeCurrent(authGeneration, expectedState);
297
+ const body = new URLSearchParams({
298
+ grant_type: 'authorization_code',
299
+ client_id: config.clientId,
300
+ code,
301
+ redirect_uri: config.redirectUri,
302
+ ...(verifier ? { code_verifier: verifier } : {}),
303
+ });
304
+ const response = await requestToken(endpoints.token_endpoint, body, 'Token exchange failed');
305
+ const session = normalizeTokenResponse(response, { tokenType: 'Bearer' }, 'Token exchange failed');
306
+ assertAuthorizationExchangeCurrent(authGeneration, expectedState);
307
+ return sessions.runAuthMutation(async () => {
308
+ assertAuthorizationExchangeCurrent(authGeneration, expectedState);
309
+ sessions.saveSession(session);
310
+ sessions.clearLoginState(expectedState);
311
+ return session;
312
+ });
313
+ }
314
+ const mapIdentity = config.mapIdentity ?? ((userinfo) => ({
315
+ id: userinfo.sub ?? '',
316
+ name: userinfo.name ?? userinfo.preferred_username ?? '',
317
+ avatar: userinfo.picture ?? undefined,
318
+ email: userinfo.email ?? undefined,
319
+ }));
320
+ const authorizationSource = {
321
+ getAuthorizationHeader: async (options) => {
322
+ const accessToken = await sessions.getAccessToken(options);
323
+ const session = sessions.getSession();
324
+ return accessToken && session?.access_token === accessToken
325
+ ? `${session.token_type} ${accessToken}`
326
+ : null;
327
+ },
328
+ };
329
+ const provider = {
330
+ async login() {
331
+ if (typeof window === 'undefined') {
332
+ return { success: false, error: { message: 'SSO login requires a browser environment' } };
333
+ }
334
+ const state = generateRandomString(32);
335
+ try {
336
+ await sessions.runAuthMutation(async () => {
337
+ sessions.beginAuthAttempt();
338
+ sessions.setLoginState('', state);
339
+ });
340
+ const endpoints = await discover();
341
+ const { verifier, challenge } = await createPKCEChallenge();
342
+ await sessions.runAuthMutation(async () => {
343
+ if (sessions.getLoginState() !== state) {
344
+ throw new SSOAuthError('Login attempt was superseded', 409, {
345
+ code: 'authorization_exchange_cancelled',
346
+ retryable: false,
347
+ });
348
+ }
349
+ sessions.setLoginState(verifier, state);
350
+ });
351
+ const params = new URLSearchParams({
352
+ response_type: 'code',
353
+ client_id: config.clientId,
354
+ redirect_uri: config.redirectUri,
355
+ scope: scopes.join(' '),
356
+ state,
357
+ code_challenge: challenge,
358
+ code_challenge_method: 'S256',
359
+ ...config.authorizationParams,
360
+ });
361
+ window.location.href = `${endpoints.authorization_endpoint}?${params}`;
362
+ return { success: true };
363
+ }
364
+ catch (error) {
365
+ try {
366
+ await clearOwnedLoginState(state);
367
+ }
368
+ catch {
369
+ // 保留触发失败的原始认证错误;清理失败不能掩盖根因。
370
+ }
371
+ return { success: false, error: getErrorResult(error) };
372
+ }
373
+ },
374
+ async logout() {
375
+ const session = sessions.getSession();
376
+ sessions.beginSignOut();
377
+ try {
378
+ await sessions.runAuthMutation(async () => {
379
+ sessions.clearSession();
380
+ });
381
+ }
382
+ catch (error) {
383
+ if (!(error instanceof SSOAuthError) || error.code !== 'auth_lock_failed')
384
+ throw error;
385
+ // 无跨上下文锁时 callback commit 同样会失败;本地登出仍应清理会话。
386
+ sessions.clearSession();
387
+ }
388
+ try {
389
+ const endpoints = await discover();
390
+ if (typeof window !== 'undefined' && endpoints.end_session_endpoint && session?.id_token) {
391
+ const params = new URLSearchParams({
392
+ id_token_hint: session.id_token,
393
+ ...(config.postLogoutRedirectUri
394
+ ? { post_logout_redirect_uri: config.postLogoutRedirectUri }
395
+ : {}),
396
+ });
397
+ window.location.href = `${endpoints.end_session_endpoint}?${params}`;
398
+ return { success: true };
399
+ }
400
+ }
401
+ catch {
402
+ // 本地会话已清理,发现或跳转失败不能恢复为登录态。
403
+ }
404
+ return {
405
+ success: true,
406
+ redirectTo: config.postLogoutRedirectUri ?? '/',
407
+ };
408
+ },
409
+ async check() {
410
+ const existingSession = sessions.getSession();
411
+ if (typeof window === 'undefined') {
412
+ const now = Math.floor(Date.now() / 1000);
413
+ return existingSession && (existingSession.expires_at === undefined || existingSession.expires_at > now)
414
+ ? { authenticated: true }
415
+ : { authenticated: false, logout: true };
416
+ }
417
+ const url = new URL(window.location.href);
418
+ const callbackError = url.searchParams.get('error');
419
+ if (callbackError) {
420
+ const callbackState = url.searchParams.get('state');
421
+ const savedState = sessions.getLoginState();
422
+ if (callbackState && savedState && callbackState === savedState) {
423
+ try {
424
+ await clearOwnedLoginState(savedState);
425
+ }
426
+ catch {
427
+ // 错误回调仍返回 IdP 的原始错误;清理失败不掩盖它。
428
+ }
429
+ }
430
+ return {
431
+ authenticated: false,
432
+ error: {
433
+ message: url.searchParams.get('error_description') ?? callbackError,
434
+ name: callbackError,
435
+ },
436
+ };
437
+ }
438
+ const code = url.searchParams.get('code');
439
+ const state = url.searchParams.get('state');
440
+ if (code) {
441
+ const savedState = sessions.getLoginState();
442
+ if (!state || !savedState || state !== savedState) {
443
+ return { authenticated: false, error: { message: 'State mismatch' } };
444
+ }
445
+ try {
446
+ await exchangeCode(code, savedState);
447
+ url.searchParams.delete('code');
448
+ url.searchParams.delete('state');
449
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
450
+ return { authenticated: true };
451
+ }
452
+ catch (error) {
453
+ try {
454
+ await clearOwnedLoginState(savedState);
455
+ }
456
+ catch {
457
+ // 保留 token exchange 的原始错误。
458
+ }
459
+ return { authenticated: false, error: getErrorResult(error) };
460
+ }
461
+ }
462
+ const session = sessions.getSession();
463
+ if (!session)
464
+ return { authenticated: false, logout: true };
465
+ const now = Math.floor(Date.now() / 1000);
466
+ if (session.expires_at !== undefined && session.expires_at <= now) {
467
+ if (!session.refresh_token) {
468
+ try {
469
+ if (await sessions.getAccessToken())
470
+ return { authenticated: true };
471
+ return sessions.getLoginState()
472
+ ? { authenticated: false }
473
+ : { authenticated: false, logout: true };
474
+ }
475
+ catch (error) {
476
+ return { authenticated: false, error: getErrorResult(error) };
477
+ }
478
+ }
479
+ try {
480
+ return await sessions.refreshSession()
481
+ ? { authenticated: true }
482
+ : { authenticated: false, logout: true };
483
+ }
484
+ catch (error) {
485
+ return {
486
+ authenticated: false,
487
+ error: getErrorResult(error),
488
+ ...(isTerminalSessionError(error) ? { logout: true } : {}),
489
+ };
490
+ }
491
+ }
492
+ return { authenticated: true };
493
+ },
494
+ async getIdentity() {
495
+ if (!sessions.getSession())
496
+ return null;
497
+ const authGeneration = sessions.getAuthGeneration();
498
+ try {
499
+ const endpoints = await discover();
500
+ const response = await buildAuthenticatedFetch(authorizationSource, getFetcher(), { requireAuthorization: true })(endpoints.userinfo_endpoint);
501
+ if (!response.ok)
502
+ return null;
503
+ const userinfo = await response.json();
504
+ if (sessions.getAuthGeneration() !== authGeneration
505
+ || !sessions.getSession())
506
+ return null;
507
+ return mapIdentity(userinfo);
508
+ }
509
+ catch {
510
+ return null;
511
+ }
512
+ },
513
+ async getPermissions() {
514
+ const idToken = sessions.getSession()?.id_token;
515
+ if (!idToken)
516
+ return null;
517
+ try {
518
+ const payload = decodeJwtPayload(idToken);
519
+ return payload?.roles ?? payload?.groups ?? payload?.permissions ?? null;
520
+ }
521
+ catch {
522
+ return null;
523
+ }
524
+ },
525
+ getSession: async () => sessions.getSession(),
526
+ refreshSession: () => sessions.refreshSession(),
527
+ getAccessToken: (options) => sessions.getAccessToken(options),
528
+ onAuthStateChange: (callback) => sessions.onAuthStateChange(callback),
529
+ createAuthenticatedFetch: (fetcher) => buildAuthenticatedFetch(authorizationSource, fetcher ?? getFetcher()),
530
+ destroy: () => sessions.destroy(),
531
+ async onError(error) {
532
+ const status = getErrorStatus(error);
533
+ if (status === 403)
534
+ return {};
535
+ if (isTerminalSessionError(error))
536
+ return { logout: true, redirectTo: '/login' };
537
+ if (status !== 401)
538
+ return {};
539
+ const session = sessions.getSession();
540
+ if (!session?.refresh_token)
541
+ return { logout: true, redirectTo: '/login' };
542
+ try {
543
+ return await sessions.getAccessToken({ forceRefresh: true })
544
+ ? {}
545
+ : { logout: true, redirectTo: '/login' };
546
+ }
547
+ catch (refreshError) {
548
+ return isTerminalSessionError(refreshError)
549
+ ? { logout: true, redirectTo: '/login' }
550
+ : {};
551
+ }
552
+ },
553
+ };
554
+ return provider;
555
+ }
@@ -0,0 +1,9 @@
1
+ import type { GetAccessTokenOptions } from './session-manager.js';
2
+ interface AccessTokenSource {
3
+ getAuthorizationHeader: (options?: GetAccessTokenOptions) => Promise<string | null>;
4
+ }
5
+ interface AuthenticatedFetchOptions {
6
+ requireAuthorization?: boolean;
7
+ }
8
+ export declare function createAuthenticatedFetch(source: AccessTokenSource, fetcher: typeof fetch, options?: AuthenticatedFetchOptions): typeof fetch;
9
+ export {};
@@ -0,0 +1,71 @@
1
+ import { SSOAuthError } from './errors.js';
2
+ function cloneReplayableRequest(request) {
3
+ try {
4
+ return request.clone();
5
+ }
6
+ catch (error) {
7
+ throw new SSOAuthError('Authenticated request body cannot be safely replayed', 0, {
8
+ code: 'request_not_replayable',
9
+ retryable: false,
10
+ cause: error,
11
+ });
12
+ }
13
+ }
14
+ function withAuthorization(request, authorization) {
15
+ if (!authorization)
16
+ return request;
17
+ const headers = new Headers(request.headers);
18
+ headers.set('Authorization', authorization);
19
+ return new Request(request, { headers });
20
+ }
21
+ function markRetryExhausted(response) {
22
+ const headers = new Headers(response.headers);
23
+ headers.set('X-Svadmin-Auth-Retry', 'exhausted');
24
+ return new Response(response.body, {
25
+ status: response.status,
26
+ statusText: response.statusText,
27
+ headers,
28
+ });
29
+ }
30
+ export function createAuthenticatedFetch(source, fetcher, options = {}) {
31
+ return (async (input, init) => {
32
+ if (input instanceof Request && input.bodyUsed) {
33
+ throw new SSOAuthError('Authenticated request body cannot be safely replayed', 0, {
34
+ code: 'request_not_replayable',
35
+ retryable: false,
36
+ });
37
+ }
38
+ let request;
39
+ try {
40
+ request = new Request(input, init);
41
+ }
42
+ catch (error) {
43
+ throw new SSOAuthError('Authenticated request could not be constructed', 0, {
44
+ code: 'request_not_replayable',
45
+ retryable: false,
46
+ cause: error,
47
+ });
48
+ }
49
+ // 两份副本都在首次发送前创建,避免流式 body 被消费后静默丢失。
50
+ const firstAttempt = cloneReplayableRequest(request);
51
+ const retryAttempt = cloneReplayableRequest(request);
52
+ const authorization = await source.getAuthorizationHeader();
53
+ if (options.requireAuthorization && !authorization) {
54
+ throw new SSOAuthError('Authenticated request requires an active session', 401, {
55
+ code: 'session_not_found',
56
+ retryable: false,
57
+ });
58
+ }
59
+ const response = await fetcher(withAuthorization(firstAttempt, authorization));
60
+ if (response.status !== 401)
61
+ return response;
62
+ const currentAuthorization = await source.getAuthorizationHeader();
63
+ const refreshedAuthorization = currentAuthorization && currentAuthorization !== authorization
64
+ ? currentAuthorization
65
+ : await source.getAuthorizationHeader({ forceRefresh: true });
66
+ if (!refreshedAuthorization)
67
+ return response;
68
+ const retryResponse = await fetcher(withAuthorization(retryAttempt, refreshedAuthorization));
69
+ return retryResponse.status === 401 ? markRetryExhausted(retryResponse) : retryResponse;
70
+ });
71
+ }
@@ -0,0 +1,19 @@
1
+ export interface SSOAuthErrorOptions {
2
+ code?: string;
3
+ details?: unknown;
4
+ body?: unknown;
5
+ retryable?: boolean;
6
+ cause?: unknown;
7
+ }
8
+ export declare class SSOAuthError extends Error {
9
+ readonly statusCode: number;
10
+ readonly code?: string;
11
+ readonly details?: unknown;
12
+ readonly body?: unknown;
13
+ readonly retryable: boolean;
14
+ readonly cause?: unknown;
15
+ constructor(message: string, statusCode: number, options?: SSOAuthErrorOptions);
16
+ }
17
+ export declare function createSSOAuthResponseError(response: Response, fallbackMessage: string): Promise<SSOAuthError>;
18
+ export declare function createSSOAuthNetworkError(error: unknown, fallbackMessage: string): SSOAuthError;
19
+ export declare function isTerminalSessionError(error: unknown): boolean;