@nihplod/sso-sdk 1.0.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/index.js ADDED
@@ -0,0 +1,788 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ OAuthError: () => OAuthError,
24
+ SsoClient: () => SsoClient,
25
+ SsoError: () => SsoError,
26
+ clearAllSsoData: () => clearAllSsoData,
27
+ clearVerifiersForClients: () => clearVerifiersForClients,
28
+ generateCodeChallenge: () => generateCodeChallenge,
29
+ generateCodeVerifier: () => generateCodeVerifier,
30
+ generateState: () => generateState,
31
+ getOAuthState: () => getOAuthState,
32
+ getPkceVerifier: () => getPkceVerifier,
33
+ getReturnUrl: () => getReturnUrl,
34
+ getTokenData: () => getTokenData,
35
+ getTokenStorage: () => getTokenStorage,
36
+ removeOAuthState: () => removeOAuthState,
37
+ removePkceVerifier: () => removePkceVerifier,
38
+ removeReturnUrl: () => removeReturnUrl,
39
+ removeTokenData: () => removeTokenData,
40
+ saveOAuthState: () => saveOAuthState,
41
+ savePkceVerifier: () => savePkceVerifier,
42
+ saveReturnUrl: () => saveReturnUrl,
43
+ saveTokenData: () => saveTokenData,
44
+ setTokenStorage: () => setTokenStorage
45
+ });
46
+ module.exports = __toCommonJS(src_exports);
47
+
48
+ // src/core/errors.ts
49
+ var SsoError = class extends Error {
50
+ constructor(code, description, cause) {
51
+ super(`[SSO SDK] ${code}: ${description}`);
52
+ this.name = "SsoError";
53
+ this.code = code;
54
+ this.description = description;
55
+ this.cause = cause;
56
+ }
57
+ };
58
+ var OAuthError = class extends Error {
59
+ constructor(code, description, uri) {
60
+ super(`[OAuth] ${code}: ${description}`);
61
+ this.name = "OAuthError";
62
+ this.code = code;
63
+ this.description = description;
64
+ this.uri = uri;
65
+ }
66
+ };
67
+
68
+ // src/core/pkce.ts
69
+ var VALID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
70
+ function generateRandomString(length) {
71
+ const mask = 198;
72
+ let result = "";
73
+ while (result.length < length) {
74
+ const chunkSize = Math.min(length * 2, 256);
75
+ const array = new Uint8Array(chunkSize);
76
+ crypto.getRandomValues(array);
77
+ for (let i = 0; i < chunkSize && result.length < length; i++) {
78
+ if (array[i] >= mask) continue;
79
+ result += VALID_CHARS[array[i] % VALID_CHARS.length];
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+ function generateCodeVerifier(length = 64) {
85
+ if (length < 43 || length > 128) {
86
+ throw new Error("code_verifier length must be between 43 and 128");
87
+ }
88
+ return generateRandomString(length);
89
+ }
90
+ async function generateCodeChallenge(codeVerifier) {
91
+ const encoder = new TextEncoder();
92
+ const data = encoder.encode(codeVerifier);
93
+ const hash = await crypto.subtle.digest("SHA-256", data);
94
+ const bytes = new Uint8Array(hash);
95
+ let binary = "";
96
+ for (let i = 0; i < bytes.byteLength; i++) {
97
+ binary += String.fromCharCode(bytes[i]);
98
+ }
99
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
100
+ }
101
+ function generateState() {
102
+ const array = new Uint8Array(32);
103
+ crypto.getRandomValues(array);
104
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
105
+ }
106
+
107
+ // src/core/storage.ts
108
+ var STORAGE_PREFIX = "nihplod_sso_";
109
+ var TOKEN_KEY = "token";
110
+ var VERIFIER_KEY_PREFIX = "pkce_verifier_";
111
+ var STATE_KEY = "oauth_state";
112
+ var RETURN_URL_KEY = "return_url";
113
+ function buildKey(base, clientId) {
114
+ return clientId ? `${base}:${clientId}` : base;
115
+ }
116
+ function createMemoryStorageAdapter() {
117
+ const store = /* @__PURE__ */ new Map();
118
+ return {
119
+ get(key) {
120
+ return store.get(key) ?? null;
121
+ },
122
+ set(key, value) {
123
+ store.set(key, value);
124
+ },
125
+ remove(key) {
126
+ store.delete(key);
127
+ }
128
+ };
129
+ }
130
+ var memoryStorageAdapter = createMemoryStorageAdapter();
131
+ var _storage = memoryStorageAdapter;
132
+ function setTokenStorage(storage) {
133
+ _storage = storage;
134
+ }
135
+ function getTokenStorage() {
136
+ return _storage;
137
+ }
138
+ function saveTokenData(data, clientId) {
139
+ _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
140
+ }
141
+ function getTokenData(clientId) {
142
+ const raw = _storage.get(buildKey(TOKEN_KEY, clientId));
143
+ if (!raw) return null;
144
+ try {
145
+ return JSON.parse(raw);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ function removeTokenData(clientId) {
151
+ _storage.remove(buildKey(TOKEN_KEY, clientId));
152
+ }
153
+ function savePkceVerifier(clientId, verifier) {
154
+ _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
155
+ }
156
+ function getPkceVerifier(clientId) {
157
+ return _storage.get(VERIFIER_KEY_PREFIX + clientId);
158
+ }
159
+ function removePkceVerifier(clientId) {
160
+ _storage.remove(VERIFIER_KEY_PREFIX + clientId);
161
+ }
162
+ function saveOAuthState(state, clientId) {
163
+ _storage.set(buildKey(STATE_KEY, clientId), state);
164
+ }
165
+ function getOAuthState(clientId) {
166
+ return _storage.get(buildKey(STATE_KEY, clientId));
167
+ }
168
+ function removeOAuthState(clientId) {
169
+ _storage.remove(buildKey(STATE_KEY, clientId));
170
+ }
171
+ function saveReturnUrl(url, clientId) {
172
+ _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
173
+ }
174
+ function getReturnUrl(clientId) {
175
+ return _storage.get(buildKey(RETURN_URL_KEY, clientId));
176
+ }
177
+ function removeReturnUrl(clientId) {
178
+ _storage.remove(buildKey(RETURN_URL_KEY, clientId));
179
+ }
180
+ function clearAllSsoData(clientId) {
181
+ if (clientId) {
182
+ removeTokenData(clientId);
183
+ removeOAuthState(clientId);
184
+ removeReturnUrl(clientId);
185
+ removePkceVerifier(clientId);
186
+ return;
187
+ }
188
+ removeTokenData();
189
+ removeOAuthState();
190
+ removeReturnUrl();
191
+ if (typeof localStorage !== "undefined") {
192
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
193
+ for (let i = 0; i < localStorage.length; i++) {
194
+ const key = localStorage.key(i);
195
+ if (key?.startsWith(prefix)) {
196
+ _storage.remove(key.slice(STORAGE_PREFIX.length));
197
+ }
198
+ }
199
+ }
200
+ }
201
+ function clearVerifiersForClients(clientIds) {
202
+ for (const clientId of clientIds) {
203
+ _storage.remove(VERIFIER_KEY_PREFIX + clientId);
204
+ }
205
+ }
206
+
207
+ // src/core/SsoClient.ts
208
+ function base64UrlDecode(input) {
209
+ const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
210
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
211
+ const binary = atob(padded);
212
+ const bytes = new Uint8Array(binary.length);
213
+ for (let i = 0; i < binary.length; i++) {
214
+ bytes[i] = binary.charCodeAt(i);
215
+ }
216
+ return bytes;
217
+ }
218
+ function decodeJwtPayload(token) {
219
+ try {
220
+ const parts = token.split(".");
221
+ if (parts.length !== 3) return null;
222
+ const json = new TextDecoder().decode(base64UrlDecode(parts[1]));
223
+ return JSON.parse(json);
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+ function decodeJwtHeader(token) {
229
+ try {
230
+ const parts = token.split(".");
231
+ if (parts.length !== 3) return null;
232
+ const json = new TextDecoder().decode(base64UrlDecode(parts[0]));
233
+ return JSON.parse(json);
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ function normalizeIssuer(url) {
239
+ return url.replace(/\/+$/, "");
240
+ }
241
+ var cachedJwks = null;
242
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
243
+ async function fetchJwks(baseUrl) {
244
+ const now = Date.now();
245
+ if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
246
+ return cachedJwks.jwks;
247
+ }
248
+ try {
249
+ const res = await fetch(`${baseUrl}/api/oauth/jwks`);
250
+ if (!res.ok) return null;
251
+ const jwks = await res.json();
252
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
253
+ return jwks;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ async function computeAtHash(accessToken) {
259
+ const hash = await crypto.subtle.digest(
260
+ "SHA-256",
261
+ new TextEncoder().encode(accessToken)
262
+ );
263
+ const bytes = new Uint8Array(hash);
264
+ const half = bytes.slice(0, bytes.length / 2);
265
+ let binary = "";
266
+ for (const b of half) {
267
+ binary += String.fromCharCode(b);
268
+ }
269
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
270
+ }
271
+ async function verifyRs256(token, jwk) {
272
+ try {
273
+ const [headerB64, payloadB64, signature] = token.split(".");
274
+ if (!signature || !jwk.n || !jwk.e) return false;
275
+ const cryptoKey = await crypto.subtle.importKey(
276
+ "jwk",
277
+ { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
278
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
279
+ false,
280
+ ["verify"]
281
+ );
282
+ const signatureBytes = base64UrlDecode(signature);
283
+ const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
284
+ return await crypto.subtle.verify(
285
+ "RSASSA-PKCS1-v1_5",
286
+ cryptoKey,
287
+ signatureBytes,
288
+ data
289
+ );
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+ async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
295
+ const header = decodeJwtHeader(idToken);
296
+ if (!header) {
297
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
298
+ }
299
+ const alg = header.alg;
300
+ if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
301
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
302
+ }
303
+ if (alg === "RS256") {
304
+ const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
305
+ if (!jwks) {
306
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
307
+ }
308
+ const kid = typeof header.kid === "string" ? header.kid : void 0;
309
+ const key = jwks.keys.find(
310
+ (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
311
+ );
312
+ if (!key) {
313
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
314
+ }
315
+ const validSig = await verifyRs256(idToken, key);
316
+ if (!validSig) {
317
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
318
+ }
319
+ }
320
+ if (alg === "HS256") {
321
+ console.warn(
322
+ "[SSO SDK] ID Token \u4F7F\u7528 HS256 \u7B7E\u540D\uFF0CPublic Client \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u7B7E\u540D\u3002\u5EFA\u8BAE\u4E3B\u7AD9\u914D\u7F6E JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY \u542F\u7528 RS256\u3002"
323
+ );
324
+ }
325
+ const payload = decodeJwtPayload(idToken);
326
+ if (!payload) {
327
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
328
+ }
329
+ const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
330
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
331
+ if (tokenIssuer !== normalizedExpectedIssuer) {
332
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
333
+ }
334
+ if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
335
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
336
+ }
337
+ if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
338
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
339
+ }
340
+ if (typeof payload.sub !== "string" || !payload.sub) {
341
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
342
+ }
343
+ if (typeof payload.at_hash === "string" && payload.at_hash) {
344
+ const actual = await computeAtHash(accessToken);
345
+ if (actual !== payload.at_hash) {
346
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
347
+ }
348
+ }
349
+ return { sub: payload.sub };
350
+ }
351
+ var _SsoClient = class _SsoClient {
352
+ constructor(config) {
353
+ this._discovery = null;
354
+ this._discoveryFetchedAt = 0;
355
+ this._refreshLock = null;
356
+ if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
357
+ if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
358
+ if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
359
+ const base = config.ssoBaseUrl.replace(/\/+$/, "");
360
+ this.config = { ...config, ssoBaseUrl: base };
361
+ }
362
+ // ============================================
363
+ // 内部方法
364
+ // ============================================
365
+ /**
366
+ * 获取 OIDC Discovery 文档(带缓存 + 超时)
367
+ *
368
+ * 缓存 5 分钟,超时 10 秒。
369
+ * 失败时返回 null(上层调用方回退到硬编码默认端点)。
370
+ */
371
+ async _getDiscovery() {
372
+ const now = Date.now();
373
+ if (this._discovery && now - this._discoveryFetchedAt < _SsoClient.DISCOVERY_TTL_MS) {
374
+ return this._discovery;
375
+ }
376
+ const url = `${this.config.ssoBaseUrl}/api/oauth/.well-known/openid-configuration`;
377
+ try {
378
+ const controller = new AbortController();
379
+ const timeoutId = setTimeout(
380
+ () => controller.abort(),
381
+ _SsoClient.DISCOVERY_TIMEOUT_MS
382
+ );
383
+ const res = await fetch(url, { signal: controller.signal });
384
+ clearTimeout(timeoutId);
385
+ if (!res.ok) {
386
+ console.warn(
387
+ `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
388
+ );
389
+ return null;
390
+ }
391
+ this._discovery = await res.json();
392
+ this._discoveryFetchedAt = now;
393
+ return this._discovery;
394
+ } catch (err) {
395
+ console.warn(
396
+ `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5F02\u5E38: ${err instanceof Error ? err.message : String(err)}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
397
+ );
398
+ return this._discovery || null;
399
+ }
400
+ }
401
+ /** 获取 authorize 端点 URL(优先 Discovery,回退默认) */
402
+ async _getAuthorizeEndpoint() {
403
+ const d = await this._getDiscovery();
404
+ if (d) return d.authorization_endpoint;
405
+ return `${this.config.ssoBaseUrl}/api/oauth/authorize`;
406
+ }
407
+ /** 获取 token 端点 URL(优先 Discovery,回退默认) */
408
+ async _getTokenEndpoint() {
409
+ const d = await this._getDiscovery();
410
+ if (d) return d.token_endpoint;
411
+ return `${this.config.ssoBaseUrl}/api/oauth/token`;
412
+ }
413
+ /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
414
+ async _getUserinfoEndpoint() {
415
+ const d = await this._getDiscovery();
416
+ if (d) return d.userinfo_endpoint;
417
+ return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
418
+ }
419
+ // ============================================
420
+ // 公共 API
421
+ // ============================================
422
+ /**
423
+ * 发起 SSO 登录
424
+ *
425
+ * 生成 PKCE code_verifier/code_challenge 和 state 参数,
426
+ * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
427
+ *
428
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
429
+ */
430
+ async login(returnUrl) {
431
+ const verifier = generateCodeVerifier();
432
+ const challenge = await generateCodeChallenge(verifier);
433
+ const state = generateState();
434
+ savePkceVerifier(this.config.clientId, verifier);
435
+ saveOAuthState(state, this.config.clientId);
436
+ if (returnUrl) {
437
+ saveReturnUrl(returnUrl);
438
+ }
439
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
440
+ const params = new URLSearchParams();
441
+ params.set("response_type", "code");
442
+ params.set("client_id", this.config.clientId);
443
+ params.set("redirect_uri", this.config.redirectUri);
444
+ params.set("scope", this.config.scopes || "openid profile");
445
+ params.set("state", state);
446
+ params.set("code_challenge", challenge);
447
+ params.set("code_challenge_method", "S256");
448
+ window.location.href = `${authorizeEndpoint}?${params.toString()}`;
449
+ }
450
+ /**
451
+ * 构建登录 URL(不跳转,返回 URL 字符串)
452
+ *
453
+ * 适用于需要手动处理跳转的场景。
454
+ */
455
+ async getLoginUrl(returnUrl) {
456
+ const verifier = generateCodeVerifier();
457
+ const challenge = await generateCodeChallenge(verifier);
458
+ const state = generateState();
459
+ savePkceVerifier(this.config.clientId, verifier);
460
+ saveOAuthState(state, this.config.clientId);
461
+ if (returnUrl) saveReturnUrl(returnUrl);
462
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
463
+ const params = new URLSearchParams();
464
+ params.set("response_type", "code");
465
+ params.set("client_id", this.config.clientId);
466
+ params.set("redirect_uri", this.config.redirectUri);
467
+ params.set("scope", this.config.scopes || "openid profile");
468
+ params.set("state", state);
469
+ params.set("code_challenge", challenge);
470
+ params.set("code_challenge_method", "S256");
471
+ return `${authorizeEndpoint}?${params.toString()}`;
472
+ }
473
+ /**
474
+ * 处理 OAuth 回调
475
+ *
476
+ * 解析回调 URL,校验 state 参数,用授权码交换 token。
477
+ * 成功后 token 自动保存到 sessionStorage。
478
+ *
479
+ * @param callbackUrl - 完整的回调 URL(window.location.href)
480
+ * @returns TokenData 或 null
481
+ */
482
+ async handleCallback(callbackUrl) {
483
+ const url = new URL(callbackUrl);
484
+ const params = url.searchParams;
485
+ const error = params.get("error");
486
+ if (error) {
487
+ const desc = params.get("error_description") || error;
488
+ throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
489
+ }
490
+ const code = params.get("code");
491
+ const returnedState = params.get("state");
492
+ if (!code) {
493
+ throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
494
+ }
495
+ const savedState = getOAuthState(this.config.clientId);
496
+ if (!savedState || savedState !== returnedState) {
497
+ removeOAuthState(this.config.clientId);
498
+ throw new SsoError(
499
+ "state_mismatch",
500
+ "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
501
+ );
502
+ }
503
+ removeOAuthState(this.config.clientId);
504
+ const verifier = getPkceVerifier(this.config.clientId);
505
+ if (!verifier) {
506
+ throw new SsoError(
507
+ "pkce_required",
508
+ "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
509
+ );
510
+ }
511
+ removePkceVerifier(this.config.clientId);
512
+ const tokenEndpoint = await this._getTokenEndpoint();
513
+ const body = new URLSearchParams();
514
+ body.set("grant_type", "authorization_code");
515
+ body.set("code", code);
516
+ body.set("code_verifier", verifier);
517
+ body.set("client_id", this.config.clientId);
518
+ body.set("redirect_uri", this.config.redirectUri);
519
+ if (this.config.clientSecret) {
520
+ body.set("client_secret", this.config.clientSecret);
521
+ }
522
+ let res;
523
+ try {
524
+ res = await fetch(tokenEndpoint, {
525
+ method: "POST",
526
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
527
+ body: body.toString()
528
+ });
529
+ } catch (err) {
530
+ throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
531
+ }
532
+ if (!res.ok) {
533
+ let errData = {};
534
+ try {
535
+ errData = await res.json();
536
+ } catch {
537
+ }
538
+ throw new SsoError(
539
+ "token_request_failed",
540
+ errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
541
+ );
542
+ }
543
+ const data = await res.json();
544
+ if (data.id_token) {
545
+ try {
546
+ await validateIdToken(
547
+ data.id_token,
548
+ this.config.ssoBaseUrl,
549
+ this.config.clientId,
550
+ data.access_token
551
+ );
552
+ } catch (err) {
553
+ removeTokenData(this.config.clientId);
554
+ throw err;
555
+ }
556
+ }
557
+ const now = Date.now();
558
+ const tokenData = {
559
+ access_token: data.access_token,
560
+ token_type: data.token_type,
561
+ expires_in: data.expires_in,
562
+ refresh_token: data.refresh_token,
563
+ id_token: data.id_token,
564
+ issued_at: now,
565
+ expires_at: now + data.expires_in * 1e3
566
+ };
567
+ saveTokenData(tokenData, this.config.clientId);
568
+ return tokenData;
569
+ }
570
+ /**
571
+ * 刷新 Access Token
572
+ *
573
+ * 使用 refresh_token 换取新的 access_token。
574
+ * 采用互斥锁防止并发刷新。
575
+ * 支持 Refresh Token 原子轮换。
576
+ */
577
+ async refreshToken() {
578
+ if (this._refreshLock) return this._refreshLock;
579
+ this._refreshLock = this._doRefreshToken();
580
+ try {
581
+ return await this._refreshLock;
582
+ } finally {
583
+ this._refreshLock = null;
584
+ }
585
+ }
586
+ async _doRefreshToken() {
587
+ const current = getTokenData(this.config.clientId);
588
+ if (!current?.refresh_token) {
589
+ throw new SsoError("no_refresh_token", "\u6CA1\u6709\u53EF\u7528\u7684 refresh_token");
590
+ }
591
+ const tokenEndpoint = await this._getTokenEndpoint();
592
+ const body = new URLSearchParams();
593
+ body.set("grant_type", "refresh_token");
594
+ body.set("refresh_token", current.refresh_token);
595
+ body.set("client_id", this.config.clientId);
596
+ if (this.config.clientSecret) {
597
+ body.set("client_secret", this.config.clientSecret);
598
+ }
599
+ let res;
600
+ try {
601
+ res = await fetch(tokenEndpoint, {
602
+ method: "POST",
603
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
604
+ body: body.toString()
605
+ });
606
+ } catch (err) {
607
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
608
+ }
609
+ if (!res.ok) {
610
+ let errData = {};
611
+ try {
612
+ errData = await res.json();
613
+ } catch {
614
+ }
615
+ const errorCode = errData.error || "";
616
+ if (errorCode === "invalid_grant" || res.status === 401) {
617
+ removeTokenData(this.config.clientId);
618
+ }
619
+ throw new SsoError(
620
+ errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
621
+ errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
622
+ );
623
+ }
624
+ const data = await res.json();
625
+ const now = Date.now();
626
+ const tokenData = {
627
+ access_token: data.access_token,
628
+ token_type: data.token_type,
629
+ expires_in: data.expires_in,
630
+ refresh_token: data.refresh_token,
631
+ id_token: data.id_token,
632
+ issued_at: now,
633
+ expires_at: now + data.expires_in * 1e3
634
+ };
635
+ saveTokenData(tokenData, this.config.clientId);
636
+ return tokenData;
637
+ }
638
+ /**
639
+ * 获取用户信息
640
+ *
641
+ * 若 access_token 已过期则自动刷新后再请求。
642
+ */
643
+ async getUserInfo() {
644
+ let tokenData = getTokenData(this.config.clientId);
645
+ if (!tokenData) {
646
+ throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
647
+ }
648
+ if (Date.now() >= tokenData.expires_at) {
649
+ tokenData = await this.refreshToken();
650
+ }
651
+ const userinfoEndpoint = await this._getUserinfoEndpoint();
652
+ let res;
653
+ try {
654
+ res = await fetch(userinfoEndpoint, {
655
+ headers: {
656
+ Authorization: `Bearer ${tokenData.access_token}`
657
+ }
658
+ });
659
+ } catch (err) {
660
+ throw new SsoError("network_error", "\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
661
+ }
662
+ if (!res.ok) {
663
+ if (res.status === 401) {
664
+ removeTokenData(this.config.clientId);
665
+ throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
666
+ }
667
+ throw new SsoError(
668
+ "userinfo_failed",
669
+ `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
670
+ );
671
+ }
672
+ return await res.json();
673
+ }
674
+ /**
675
+ * 获取当前 access_token
676
+ *
677
+ * 若已过期则自动刷新。用于子项目自行发起 API 请求时获取 Bearer token。
678
+ * 若无 token 返回 null;若刷新失败则抛出错误(与 getUserInfo 行为一致)。
679
+ */
680
+ async getAccessToken() {
681
+ let tokenData = getTokenData(this.config.clientId);
682
+ if (!tokenData) return null;
683
+ if (Date.now() >= tokenData.expires_at) {
684
+ tokenData = await this.refreshToken();
685
+ }
686
+ return tokenData.access_token;
687
+ }
688
+ /**
689
+ * 检查是否已认证(不发起网络请求)
690
+ *
691
+ * 仅检查本地是否存在未过期的 access_token。
692
+ */
693
+ isAuthenticated() {
694
+ const tokenData = getTokenData(this.config.clientId);
695
+ if (!tokenData) return false;
696
+ return Date.now() < tokenData.expires_at;
697
+ }
698
+ /**
699
+ * 登出
700
+ *
701
+ * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
702
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
703
+ */
704
+ async logout(redirectToSso = false) {
705
+ const tokenData = getTokenData(this.config.clientId);
706
+ const refreshToken = tokenData?.refresh_token;
707
+ const idTokenHint = tokenData?.id_token;
708
+ clearAllSsoData(this.config.clientId);
709
+ if (refreshToken && this.config.clientId) {
710
+ try {
711
+ const discovery = await this._getDiscovery();
712
+ const revokeUrl = discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
713
+ const revokeBody = new URLSearchParams({
714
+ token: refreshToken,
715
+ token_type_hint: "refresh_token",
716
+ client_id: this.config.clientId
717
+ });
718
+ if (this.config.clientSecret) {
719
+ revokeBody.set("client_secret", this.config.clientSecret);
720
+ }
721
+ await fetch(revokeUrl, {
722
+ method: "POST",
723
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
724
+ body: revokeBody.toString()
725
+ });
726
+ } catch {
727
+ }
728
+ }
729
+ if (redirectToSso) {
730
+ const discovery = await this._getDiscovery();
731
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
732
+ const logoutUrl = new URL(endSessionEndpoint);
733
+ logoutUrl.searchParams.set("client_id", this.config.clientId);
734
+ if (this.config.redirectUri) {
735
+ logoutUrl.searchParams.set(
736
+ "post_logout_redirect_uri",
737
+ this.config.redirectUri
738
+ );
739
+ }
740
+ if (idTokenHint) {
741
+ logoutUrl.searchParams.set("id_token_hint", idTokenHint);
742
+ }
743
+ const state = generateState();
744
+ logoutUrl.searchParams.set("state", state);
745
+ window.location.href = logoutUrl.toString();
746
+ }
747
+ }
748
+ /**
749
+ * 获取 OIDC Discovery 文档
750
+ *
751
+ * 用于调试和获取 SSO 中心完整配置。
752
+ * 可能返回 null(当 Discovery 端点不可达且无缓存时)。
753
+ */
754
+ async getDiscovery() {
755
+ return this._getDiscovery();
756
+ }
757
+ };
758
+ /** Discovery 文档缓存 TTL(5 分钟) */
759
+ _SsoClient.DISCOVERY_TTL_MS = 5 * 60 * 1e3;
760
+ /** Discovery fetch 超时(10 秒) */
761
+ _SsoClient.DISCOVERY_TIMEOUT_MS = 1e4;
762
+ var SsoClient = _SsoClient;
763
+ // Annotate the CommonJS export names for ESM import in node:
764
+ 0 && (module.exports = {
765
+ OAuthError,
766
+ SsoClient,
767
+ SsoError,
768
+ clearAllSsoData,
769
+ clearVerifiersForClients,
770
+ generateCodeChallenge,
771
+ generateCodeVerifier,
772
+ generateState,
773
+ getOAuthState,
774
+ getPkceVerifier,
775
+ getReturnUrl,
776
+ getTokenData,
777
+ getTokenStorage,
778
+ removeOAuthState,
779
+ removePkceVerifier,
780
+ removeReturnUrl,
781
+ removeTokenData,
782
+ saveOAuthState,
783
+ savePkceVerifier,
784
+ saveReturnUrl,
785
+ saveTokenData,
786
+ setTokenStorage
787
+ });
788
+ //# sourceMappingURL=index.js.map