@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.
@@ -0,0 +1,1003 @@
1
+ // src/react/SsoProvider.tsx
2
+ import React, {
3
+ createContext,
4
+ useContext,
5
+ useState,
6
+ useEffect,
7
+ useCallback,
8
+ useRef
9
+ } from "react";
10
+
11
+ // src/core/errors.ts
12
+ var SsoError = class extends Error {
13
+ constructor(code, description, cause) {
14
+ super(`[SSO SDK] ${code}: ${description}`);
15
+ this.name = "SsoError";
16
+ this.code = code;
17
+ this.description = description;
18
+ this.cause = cause;
19
+ }
20
+ };
21
+
22
+ // src/core/pkce.ts
23
+ var VALID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
24
+ function generateRandomString(length) {
25
+ const mask = 198;
26
+ let result = "";
27
+ while (result.length < length) {
28
+ const chunkSize = Math.min(length * 2, 256);
29
+ const array = new Uint8Array(chunkSize);
30
+ crypto.getRandomValues(array);
31
+ for (let i = 0; i < chunkSize && result.length < length; i++) {
32
+ if (array[i] >= mask) continue;
33
+ result += VALID_CHARS[array[i] % VALID_CHARS.length];
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+ function generateCodeVerifier(length = 64) {
39
+ if (length < 43 || length > 128) {
40
+ throw new Error("code_verifier length must be between 43 and 128");
41
+ }
42
+ return generateRandomString(length);
43
+ }
44
+ async function generateCodeChallenge(codeVerifier) {
45
+ const encoder = new TextEncoder();
46
+ const data = encoder.encode(codeVerifier);
47
+ const hash = await crypto.subtle.digest("SHA-256", data);
48
+ const bytes = new Uint8Array(hash);
49
+ let binary = "";
50
+ for (let i = 0; i < bytes.byteLength; i++) {
51
+ binary += String.fromCharCode(bytes[i]);
52
+ }
53
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
54
+ }
55
+ function generateState() {
56
+ const array = new Uint8Array(32);
57
+ crypto.getRandomValues(array);
58
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
59
+ }
60
+
61
+ // src/core/storage.ts
62
+ var STORAGE_PREFIX = "nihplod_sso_";
63
+ var TOKEN_KEY = "token";
64
+ var VERIFIER_KEY_PREFIX = "pkce_verifier_";
65
+ var STATE_KEY = "oauth_state";
66
+ var RETURN_URL_KEY = "return_url";
67
+ function buildKey(base, clientId) {
68
+ return clientId ? `${base}:${clientId}` : base;
69
+ }
70
+ function createMemoryStorageAdapter() {
71
+ const store = /* @__PURE__ */ new Map();
72
+ return {
73
+ get(key) {
74
+ return store.get(key) ?? null;
75
+ },
76
+ set(key, value) {
77
+ store.set(key, value);
78
+ },
79
+ remove(key) {
80
+ store.delete(key);
81
+ }
82
+ };
83
+ }
84
+ var memoryStorageAdapter = createMemoryStorageAdapter();
85
+ var _storage = memoryStorageAdapter;
86
+ function saveTokenData(data, clientId) {
87
+ _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
88
+ }
89
+ function getTokenData(clientId) {
90
+ const raw = _storage.get(buildKey(TOKEN_KEY, clientId));
91
+ if (!raw) return null;
92
+ try {
93
+ return JSON.parse(raw);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function removeTokenData(clientId) {
99
+ _storage.remove(buildKey(TOKEN_KEY, clientId));
100
+ }
101
+ function savePkceVerifier(clientId, verifier) {
102
+ _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
103
+ }
104
+ function getPkceVerifier(clientId) {
105
+ return _storage.get(VERIFIER_KEY_PREFIX + clientId);
106
+ }
107
+ function removePkceVerifier(clientId) {
108
+ _storage.remove(VERIFIER_KEY_PREFIX + clientId);
109
+ }
110
+ function saveOAuthState(state, clientId) {
111
+ _storage.set(buildKey(STATE_KEY, clientId), state);
112
+ }
113
+ function getOAuthState(clientId) {
114
+ return _storage.get(buildKey(STATE_KEY, clientId));
115
+ }
116
+ function removeOAuthState(clientId) {
117
+ _storage.remove(buildKey(STATE_KEY, clientId));
118
+ }
119
+ function saveReturnUrl(url, clientId) {
120
+ _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
121
+ }
122
+ function getReturnUrl(clientId) {
123
+ return _storage.get(buildKey(RETURN_URL_KEY, clientId));
124
+ }
125
+ function removeReturnUrl(clientId) {
126
+ _storage.remove(buildKey(RETURN_URL_KEY, clientId));
127
+ }
128
+ function clearAllSsoData(clientId) {
129
+ if (clientId) {
130
+ removeTokenData(clientId);
131
+ removeOAuthState(clientId);
132
+ removeReturnUrl(clientId);
133
+ removePkceVerifier(clientId);
134
+ return;
135
+ }
136
+ removeTokenData();
137
+ removeOAuthState();
138
+ removeReturnUrl();
139
+ if (typeof localStorage !== "undefined") {
140
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
141
+ for (let i = 0; i < localStorage.length; i++) {
142
+ const key = localStorage.key(i);
143
+ if (key?.startsWith(prefix)) {
144
+ _storage.remove(key.slice(STORAGE_PREFIX.length));
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // src/core/SsoClient.ts
151
+ function base64UrlDecode(input) {
152
+ const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
153
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
154
+ const binary = atob(padded);
155
+ const bytes = new Uint8Array(binary.length);
156
+ for (let i = 0; i < binary.length; i++) {
157
+ bytes[i] = binary.charCodeAt(i);
158
+ }
159
+ return bytes;
160
+ }
161
+ function decodeJwtPayload(token) {
162
+ try {
163
+ const parts = token.split(".");
164
+ if (parts.length !== 3) return null;
165
+ const json = new TextDecoder().decode(base64UrlDecode(parts[1]));
166
+ return JSON.parse(json);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ function decodeJwtHeader(token) {
172
+ try {
173
+ const parts = token.split(".");
174
+ if (parts.length !== 3) return null;
175
+ const json = new TextDecoder().decode(base64UrlDecode(parts[0]));
176
+ return JSON.parse(json);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ function normalizeIssuer(url) {
182
+ return url.replace(/\/+$/, "");
183
+ }
184
+ var cachedJwks = null;
185
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
186
+ async function fetchJwks(baseUrl) {
187
+ const now = Date.now();
188
+ if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
189
+ return cachedJwks.jwks;
190
+ }
191
+ try {
192
+ const res = await fetch(`${baseUrl}/api/oauth/jwks`);
193
+ if (!res.ok) return null;
194
+ const jwks = await res.json();
195
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
196
+ return jwks;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ async function computeAtHash(accessToken) {
202
+ const hash = await crypto.subtle.digest(
203
+ "SHA-256",
204
+ new TextEncoder().encode(accessToken)
205
+ );
206
+ const bytes = new Uint8Array(hash);
207
+ const half = bytes.slice(0, bytes.length / 2);
208
+ let binary = "";
209
+ for (const b of half) {
210
+ binary += String.fromCharCode(b);
211
+ }
212
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
213
+ }
214
+ async function verifyRs256(token, jwk) {
215
+ try {
216
+ const [headerB64, payloadB64, signature] = token.split(".");
217
+ if (!signature || !jwk.n || !jwk.e) return false;
218
+ const cryptoKey = await crypto.subtle.importKey(
219
+ "jwk",
220
+ { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
221
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
222
+ false,
223
+ ["verify"]
224
+ );
225
+ const signatureBytes = base64UrlDecode(signature);
226
+ const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
227
+ return await crypto.subtle.verify(
228
+ "RSASSA-PKCS1-v1_5",
229
+ cryptoKey,
230
+ signatureBytes,
231
+ data
232
+ );
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+ async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
238
+ const header = decodeJwtHeader(idToken);
239
+ if (!header) {
240
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
241
+ }
242
+ const alg = header.alg;
243
+ if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
244
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
245
+ }
246
+ if (alg === "RS256") {
247
+ const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
248
+ if (!jwks) {
249
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
250
+ }
251
+ const kid = typeof header.kid === "string" ? header.kid : void 0;
252
+ const key = jwks.keys.find(
253
+ (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
254
+ );
255
+ if (!key) {
256
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
257
+ }
258
+ const validSig = await verifyRs256(idToken, key);
259
+ if (!validSig) {
260
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
261
+ }
262
+ }
263
+ if (alg === "HS256") {
264
+ console.warn(
265
+ "[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"
266
+ );
267
+ }
268
+ const payload = decodeJwtPayload(idToken);
269
+ if (!payload) {
270
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
271
+ }
272
+ const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
273
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
274
+ if (tokenIssuer !== normalizedExpectedIssuer) {
275
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
276
+ }
277
+ if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
278
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
279
+ }
280
+ if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
281
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
282
+ }
283
+ if (typeof payload.sub !== "string" || !payload.sub) {
284
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
285
+ }
286
+ if (typeof payload.at_hash === "string" && payload.at_hash) {
287
+ const actual = await computeAtHash(accessToken);
288
+ if (actual !== payload.at_hash) {
289
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
290
+ }
291
+ }
292
+ return { sub: payload.sub };
293
+ }
294
+ var _SsoClient = class _SsoClient {
295
+ constructor(config) {
296
+ this._discovery = null;
297
+ this._discoveryFetchedAt = 0;
298
+ this._refreshLock = null;
299
+ if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
300
+ if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
301
+ if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
302
+ const base = config.ssoBaseUrl.replace(/\/+$/, "");
303
+ this.config = { ...config, ssoBaseUrl: base };
304
+ }
305
+ // ============================================
306
+ // 内部方法
307
+ // ============================================
308
+ /**
309
+ * 获取 OIDC Discovery 文档(带缓存 + 超时)
310
+ *
311
+ * 缓存 5 分钟,超时 10 秒。
312
+ * 失败时返回 null(上层调用方回退到硬编码默认端点)。
313
+ */
314
+ async _getDiscovery() {
315
+ const now = Date.now();
316
+ if (this._discovery && now - this._discoveryFetchedAt < _SsoClient.DISCOVERY_TTL_MS) {
317
+ return this._discovery;
318
+ }
319
+ const url = `${this.config.ssoBaseUrl}/api/oauth/.well-known/openid-configuration`;
320
+ try {
321
+ const controller = new AbortController();
322
+ const timeoutId = setTimeout(
323
+ () => controller.abort(),
324
+ _SsoClient.DISCOVERY_TIMEOUT_MS
325
+ );
326
+ const res = await fetch(url, { signal: controller.signal });
327
+ clearTimeout(timeoutId);
328
+ if (!res.ok) {
329
+ console.warn(
330
+ `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
331
+ );
332
+ return null;
333
+ }
334
+ this._discovery = await res.json();
335
+ this._discoveryFetchedAt = now;
336
+ return this._discovery;
337
+ } catch (err) {
338
+ console.warn(
339
+ `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5F02\u5E38: ${err instanceof Error ? err.message : String(err)}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
340
+ );
341
+ return this._discovery || null;
342
+ }
343
+ }
344
+ /** 获取 authorize 端点 URL(优先 Discovery,回退默认) */
345
+ async _getAuthorizeEndpoint() {
346
+ const d = await this._getDiscovery();
347
+ if (d) return d.authorization_endpoint;
348
+ return `${this.config.ssoBaseUrl}/api/oauth/authorize`;
349
+ }
350
+ /** 获取 token 端点 URL(优先 Discovery,回退默认) */
351
+ async _getTokenEndpoint() {
352
+ const d = await this._getDiscovery();
353
+ if (d) return d.token_endpoint;
354
+ return `${this.config.ssoBaseUrl}/api/oauth/token`;
355
+ }
356
+ /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
357
+ async _getUserinfoEndpoint() {
358
+ const d = await this._getDiscovery();
359
+ if (d) return d.userinfo_endpoint;
360
+ return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
361
+ }
362
+ // ============================================
363
+ // 公共 API
364
+ // ============================================
365
+ /**
366
+ * 发起 SSO 登录
367
+ *
368
+ * 生成 PKCE code_verifier/code_challenge 和 state 参数,
369
+ * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
370
+ *
371
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
372
+ */
373
+ async login(returnUrl) {
374
+ const verifier = generateCodeVerifier();
375
+ const challenge = await generateCodeChallenge(verifier);
376
+ const state = generateState();
377
+ savePkceVerifier(this.config.clientId, verifier);
378
+ saveOAuthState(state, this.config.clientId);
379
+ if (returnUrl) {
380
+ saveReturnUrl(returnUrl);
381
+ }
382
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
383
+ const params = new URLSearchParams();
384
+ params.set("response_type", "code");
385
+ params.set("client_id", this.config.clientId);
386
+ params.set("redirect_uri", this.config.redirectUri);
387
+ params.set("scope", this.config.scopes || "openid profile");
388
+ params.set("state", state);
389
+ params.set("code_challenge", challenge);
390
+ params.set("code_challenge_method", "S256");
391
+ window.location.href = `${authorizeEndpoint}?${params.toString()}`;
392
+ }
393
+ /**
394
+ * 构建登录 URL(不跳转,返回 URL 字符串)
395
+ *
396
+ * 适用于需要手动处理跳转的场景。
397
+ */
398
+ async getLoginUrl(returnUrl) {
399
+ const verifier = generateCodeVerifier();
400
+ const challenge = await generateCodeChallenge(verifier);
401
+ const state = generateState();
402
+ savePkceVerifier(this.config.clientId, verifier);
403
+ saveOAuthState(state, this.config.clientId);
404
+ if (returnUrl) saveReturnUrl(returnUrl);
405
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
406
+ const params = new URLSearchParams();
407
+ params.set("response_type", "code");
408
+ params.set("client_id", this.config.clientId);
409
+ params.set("redirect_uri", this.config.redirectUri);
410
+ params.set("scope", this.config.scopes || "openid profile");
411
+ params.set("state", state);
412
+ params.set("code_challenge", challenge);
413
+ params.set("code_challenge_method", "S256");
414
+ return `${authorizeEndpoint}?${params.toString()}`;
415
+ }
416
+ /**
417
+ * 处理 OAuth 回调
418
+ *
419
+ * 解析回调 URL,校验 state 参数,用授权码交换 token。
420
+ * 成功后 token 自动保存到 sessionStorage。
421
+ *
422
+ * @param callbackUrl - 完整的回调 URL(window.location.href)
423
+ * @returns TokenData 或 null
424
+ */
425
+ async handleCallback(callbackUrl) {
426
+ const url = new URL(callbackUrl);
427
+ const params = url.searchParams;
428
+ const error = params.get("error");
429
+ if (error) {
430
+ const desc = params.get("error_description") || error;
431
+ throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
432
+ }
433
+ const code = params.get("code");
434
+ const returnedState = params.get("state");
435
+ if (!code) {
436
+ throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
437
+ }
438
+ const savedState = getOAuthState(this.config.clientId);
439
+ if (!savedState || savedState !== returnedState) {
440
+ removeOAuthState(this.config.clientId);
441
+ throw new SsoError(
442
+ "state_mismatch",
443
+ "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
444
+ );
445
+ }
446
+ removeOAuthState(this.config.clientId);
447
+ const verifier = getPkceVerifier(this.config.clientId);
448
+ if (!verifier) {
449
+ throw new SsoError(
450
+ "pkce_required",
451
+ "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
452
+ );
453
+ }
454
+ removePkceVerifier(this.config.clientId);
455
+ const tokenEndpoint = await this._getTokenEndpoint();
456
+ const body = new URLSearchParams();
457
+ body.set("grant_type", "authorization_code");
458
+ body.set("code", code);
459
+ body.set("code_verifier", verifier);
460
+ body.set("client_id", this.config.clientId);
461
+ body.set("redirect_uri", this.config.redirectUri);
462
+ if (this.config.clientSecret) {
463
+ body.set("client_secret", this.config.clientSecret);
464
+ }
465
+ let res;
466
+ try {
467
+ res = await fetch(tokenEndpoint, {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
470
+ body: body.toString()
471
+ });
472
+ } catch (err) {
473
+ throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
474
+ }
475
+ if (!res.ok) {
476
+ let errData = {};
477
+ try {
478
+ errData = await res.json();
479
+ } catch {
480
+ }
481
+ throw new SsoError(
482
+ "token_request_failed",
483
+ errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
484
+ );
485
+ }
486
+ const data = await res.json();
487
+ if (data.id_token) {
488
+ try {
489
+ await validateIdToken(
490
+ data.id_token,
491
+ this.config.ssoBaseUrl,
492
+ this.config.clientId,
493
+ data.access_token
494
+ );
495
+ } catch (err) {
496
+ removeTokenData(this.config.clientId);
497
+ throw err;
498
+ }
499
+ }
500
+ const now = Date.now();
501
+ const tokenData = {
502
+ access_token: data.access_token,
503
+ token_type: data.token_type,
504
+ expires_in: data.expires_in,
505
+ refresh_token: data.refresh_token,
506
+ id_token: data.id_token,
507
+ issued_at: now,
508
+ expires_at: now + data.expires_in * 1e3
509
+ };
510
+ saveTokenData(tokenData, this.config.clientId);
511
+ return tokenData;
512
+ }
513
+ /**
514
+ * 刷新 Access Token
515
+ *
516
+ * 使用 refresh_token 换取新的 access_token。
517
+ * 采用互斥锁防止并发刷新。
518
+ * 支持 Refresh Token 原子轮换。
519
+ */
520
+ async refreshToken() {
521
+ if (this._refreshLock) return this._refreshLock;
522
+ this._refreshLock = this._doRefreshToken();
523
+ try {
524
+ return await this._refreshLock;
525
+ } finally {
526
+ this._refreshLock = null;
527
+ }
528
+ }
529
+ async _doRefreshToken() {
530
+ const current = getTokenData(this.config.clientId);
531
+ if (!current?.refresh_token) {
532
+ throw new SsoError("no_refresh_token", "\u6CA1\u6709\u53EF\u7528\u7684 refresh_token");
533
+ }
534
+ const tokenEndpoint = await this._getTokenEndpoint();
535
+ const body = new URLSearchParams();
536
+ body.set("grant_type", "refresh_token");
537
+ body.set("refresh_token", current.refresh_token);
538
+ body.set("client_id", this.config.clientId);
539
+ if (this.config.clientSecret) {
540
+ body.set("client_secret", this.config.clientSecret);
541
+ }
542
+ let res;
543
+ try {
544
+ res = await fetch(tokenEndpoint, {
545
+ method: "POST",
546
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
547
+ body: body.toString()
548
+ });
549
+ } catch (err) {
550
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
551
+ }
552
+ if (!res.ok) {
553
+ let errData = {};
554
+ try {
555
+ errData = await res.json();
556
+ } catch {
557
+ }
558
+ const errorCode = errData.error || "";
559
+ if (errorCode === "invalid_grant" || res.status === 401) {
560
+ removeTokenData(this.config.clientId);
561
+ }
562
+ throw new SsoError(
563
+ errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
564
+ errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
565
+ );
566
+ }
567
+ const data = await res.json();
568
+ const now = Date.now();
569
+ const tokenData = {
570
+ access_token: data.access_token,
571
+ token_type: data.token_type,
572
+ expires_in: data.expires_in,
573
+ refresh_token: data.refresh_token,
574
+ id_token: data.id_token,
575
+ issued_at: now,
576
+ expires_at: now + data.expires_in * 1e3
577
+ };
578
+ saveTokenData(tokenData, this.config.clientId);
579
+ return tokenData;
580
+ }
581
+ /**
582
+ * 获取用户信息
583
+ *
584
+ * 若 access_token 已过期则自动刷新后再请求。
585
+ */
586
+ async getUserInfo() {
587
+ let tokenData = getTokenData(this.config.clientId);
588
+ if (!tokenData) {
589
+ throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
590
+ }
591
+ if (Date.now() >= tokenData.expires_at) {
592
+ tokenData = await this.refreshToken();
593
+ }
594
+ const userinfoEndpoint = await this._getUserinfoEndpoint();
595
+ let res;
596
+ try {
597
+ res = await fetch(userinfoEndpoint, {
598
+ headers: {
599
+ Authorization: `Bearer ${tokenData.access_token}`
600
+ }
601
+ });
602
+ } catch (err) {
603
+ throw new SsoError("network_error", "\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
604
+ }
605
+ if (!res.ok) {
606
+ if (res.status === 401) {
607
+ removeTokenData(this.config.clientId);
608
+ throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
609
+ }
610
+ throw new SsoError(
611
+ "userinfo_failed",
612
+ `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
613
+ );
614
+ }
615
+ return await res.json();
616
+ }
617
+ /**
618
+ * 获取当前 access_token
619
+ *
620
+ * 若已过期则自动刷新。用于子项目自行发起 API 请求时获取 Bearer token。
621
+ * 若无 token 返回 null;若刷新失败则抛出错误(与 getUserInfo 行为一致)。
622
+ */
623
+ async getAccessToken() {
624
+ let tokenData = getTokenData(this.config.clientId);
625
+ if (!tokenData) return null;
626
+ if (Date.now() >= tokenData.expires_at) {
627
+ tokenData = await this.refreshToken();
628
+ }
629
+ return tokenData.access_token;
630
+ }
631
+ /**
632
+ * 检查是否已认证(不发起网络请求)
633
+ *
634
+ * 仅检查本地是否存在未过期的 access_token。
635
+ */
636
+ isAuthenticated() {
637
+ const tokenData = getTokenData(this.config.clientId);
638
+ if (!tokenData) return false;
639
+ return Date.now() < tokenData.expires_at;
640
+ }
641
+ /**
642
+ * 登出
643
+ *
644
+ * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
645
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
646
+ */
647
+ async logout(redirectToSso = false) {
648
+ const tokenData = getTokenData(this.config.clientId);
649
+ const refreshToken = tokenData?.refresh_token;
650
+ const idTokenHint = tokenData?.id_token;
651
+ clearAllSsoData(this.config.clientId);
652
+ if (refreshToken && this.config.clientId) {
653
+ try {
654
+ const discovery = await this._getDiscovery();
655
+ const revokeUrl = discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
656
+ const revokeBody = new URLSearchParams({
657
+ token: refreshToken,
658
+ token_type_hint: "refresh_token",
659
+ client_id: this.config.clientId
660
+ });
661
+ if (this.config.clientSecret) {
662
+ revokeBody.set("client_secret", this.config.clientSecret);
663
+ }
664
+ await fetch(revokeUrl, {
665
+ method: "POST",
666
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
667
+ body: revokeBody.toString()
668
+ });
669
+ } catch {
670
+ }
671
+ }
672
+ if (redirectToSso) {
673
+ const discovery = await this._getDiscovery();
674
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
675
+ const logoutUrl = new URL(endSessionEndpoint);
676
+ logoutUrl.searchParams.set("client_id", this.config.clientId);
677
+ if (this.config.redirectUri) {
678
+ logoutUrl.searchParams.set(
679
+ "post_logout_redirect_uri",
680
+ this.config.redirectUri
681
+ );
682
+ }
683
+ if (idTokenHint) {
684
+ logoutUrl.searchParams.set("id_token_hint", idTokenHint);
685
+ }
686
+ const state = generateState();
687
+ logoutUrl.searchParams.set("state", state);
688
+ window.location.href = logoutUrl.toString();
689
+ }
690
+ }
691
+ /**
692
+ * 获取 OIDC Discovery 文档
693
+ *
694
+ * 用于调试和获取 SSO 中心完整配置。
695
+ * 可能返回 null(当 Discovery 端点不可达且无缓存时)。
696
+ */
697
+ async getDiscovery() {
698
+ return this._getDiscovery();
699
+ }
700
+ };
701
+ /** Discovery 文档缓存 TTL(5 分钟) */
702
+ _SsoClient.DISCOVERY_TTL_MS = 5 * 60 * 1e3;
703
+ /** Discovery fetch 超时(10 秒) */
704
+ _SsoClient.DISCOVERY_TIMEOUT_MS = 1e4;
705
+ var SsoClient = _SsoClient;
706
+
707
+ // src/react/SsoProvider.tsx
708
+ var SsoContext = createContext(null);
709
+ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
710
+ var LOCK_TTL_MS = 5e3;
711
+ function lockKey(clientId) {
712
+ return REFRESH_LOCK_PREFIX + clientId;
713
+ }
714
+ function acquireRefreshLock(clientId) {
715
+ if (typeof localStorage === "undefined") return true;
716
+ const key = lockKey(clientId);
717
+ const now = Date.now();
718
+ const raw = localStorage.getItem(key);
719
+ if (raw) {
720
+ const ts = parseInt(raw, 10);
721
+ if (!isNaN(ts) && now - ts < LOCK_TTL_MS) {
722
+ return false;
723
+ }
724
+ }
725
+ localStorage.setItem(key, String(now));
726
+ return true;
727
+ }
728
+ function releaseRefreshLock(clientId) {
729
+ if (typeof localStorage === "undefined") return;
730
+ localStorage.removeItem(lockKey(clientId));
731
+ }
732
+ function SsoProvider({
733
+ config,
734
+ children,
735
+ refreshThreshold = 60,
736
+ onTokenRefreshed
737
+ }) {
738
+ const [user, setUser] = useState(null);
739
+ const [isLoading, setIsLoading] = useState(true);
740
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
741
+ const clientRef = useRef(new SsoClient(config));
742
+ const refreshTimerRef = useRef(null);
743
+ const loadedRef = useRef(false);
744
+ const client = clientRef.current;
745
+ const loadUser = useCallback(async () => {
746
+ if (!client.isAuthenticated()) {
747
+ setUser(null);
748
+ setIsAuthenticated(false);
749
+ setIsLoading(false);
750
+ return;
751
+ }
752
+ try {
753
+ const u = await client.getUserInfo();
754
+ setUser(u);
755
+ setIsAuthenticated(true);
756
+ } catch {
757
+ setUser(null);
758
+ setIsAuthenticated(false);
759
+ removeTokenData(client.config.clientId);
760
+ } finally {
761
+ setIsLoading(false);
762
+ }
763
+ }, [client]);
764
+ useEffect(() => {
765
+ if (loadedRef.current) return;
766
+ loadedRef.current = true;
767
+ if (client.isAuthenticated()) {
768
+ loadUser();
769
+ } else {
770
+ setIsLoading(false);
771
+ }
772
+ }, [client, loadUser]);
773
+ useEffect(() => {
774
+ let active = true;
775
+ const scheduleNextRefresh = () => {
776
+ if (!active) return;
777
+ if (refreshTimerRef.current) {
778
+ clearTimeout(refreshTimerRef.current);
779
+ refreshTimerRef.current = null;
780
+ }
781
+ const tokenData = getTokenData(client.config.clientId);
782
+ if (!tokenData) return;
783
+ const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
784
+ if (remainingSec <= 0) {
785
+ if (!acquireRefreshLock(client.config.clientId)) return;
786
+ client.refreshToken().then((td) => {
787
+ onTokenRefreshed?.(td.access_token);
788
+ loadUser();
789
+ }).catch(() => {
790
+ setTimeout(() => loadUser(), 500);
791
+ }).finally(() => releaseRefreshLock(client.config.clientId));
792
+ return;
793
+ }
794
+ if (remainingSec <= refreshThreshold) {
795
+ if (!acquireRefreshLock(client.config.clientId)) return;
796
+ client.refreshToken().then((td) => {
797
+ onTokenRefreshed?.(td.access_token);
798
+ loadUser();
799
+ }).catch(() => {
800
+ setTimeout(() => loadUser(), 500);
801
+ }).finally(() => releaseRefreshLock(client.config.clientId));
802
+ return;
803
+ }
804
+ const delayMs = (remainingSec - refreshThreshold) * 1e3;
805
+ refreshTimerRef.current = setTimeout(() => {
806
+ const td = getTokenData(client.config.clientId);
807
+ if (!td) return;
808
+ const secLeft = (td.expires_at - Date.now()) / 1e3;
809
+ if (secLeft <= refreshThreshold) {
810
+ if (!acquireRefreshLock(client.config.clientId)) {
811
+ return;
812
+ }
813
+ client.refreshToken().then((newTd) => {
814
+ onTokenRefreshed?.(newTd.access_token);
815
+ loadUser();
816
+ }).catch(() => {
817
+ setTimeout(() => loadUser(), 500);
818
+ }).finally(() => releaseRefreshLock(client.config.clientId));
819
+ scheduleNextRefresh();
820
+ }
821
+ }, Math.max(delayMs, 1e3));
822
+ };
823
+ scheduleNextRefresh();
824
+ return () => {
825
+ active = false;
826
+ if (refreshTimerRef.current) {
827
+ clearTimeout(refreshTimerRef.current);
828
+ refreshTimerRef.current = null;
829
+ }
830
+ };
831
+ }, [client, loadUser, refreshThreshold, onTokenRefreshed]);
832
+ useEffect(() => {
833
+ const handleStorageChange = (e) => {
834
+ if (e.key && e.key.startsWith("nihplod_sso_") && !e.key.startsWith(REFRESH_LOCK_PREFIX)) {
835
+ loadUser();
836
+ }
837
+ };
838
+ window.addEventListener("storage", handleStorageChange);
839
+ return () => window.removeEventListener("storage", handleStorageChange);
840
+ }, [loadUser]);
841
+ const login = useCallback(
842
+ async (returnUrl) => {
843
+ await client.login(returnUrl);
844
+ },
845
+ [client]
846
+ );
847
+ const logout = useCallback(
848
+ async (redirectToSso = false) => {
849
+ await client.logout(redirectToSso);
850
+ setUser(null);
851
+ setIsAuthenticated(false);
852
+ },
853
+ [client]
854
+ );
855
+ const refreshUser = useCallback(async () => {
856
+ await loadUser();
857
+ }, [loadUser]);
858
+ const getAccessToken = useCallback(async () => {
859
+ return client.getAccessToken();
860
+ }, [client]);
861
+ const value = {
862
+ user,
863
+ isAuthenticated,
864
+ isLoading,
865
+ login,
866
+ logout,
867
+ refreshUser,
868
+ getAccessToken,
869
+ client
870
+ };
871
+ return React.createElement(SsoContext.Provider, { value }, children);
872
+ }
873
+ function useSso() {
874
+ const ctx = useContext(SsoContext);
875
+ if (!ctx) {
876
+ throw new Error("useSso() \u5FC5\u987B\u5728 <SsoProvider> \u5185\u90E8\u4F7F\u7528");
877
+ }
878
+ return ctx;
879
+ }
880
+
881
+ // src/react/RequireAuth.tsx
882
+ import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
883
+ function RequireAuth({
884
+ children,
885
+ fallback,
886
+ autoLogin = true
887
+ }) {
888
+ const { isAuthenticated, isLoading, login } = useSso();
889
+ const loginTriggeredRef = useRef2(false);
890
+ useEffect2(() => {
891
+ if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
892
+ loginTriggeredRef.current = true;
893
+ const currentPath = window.location.pathname + window.location.search;
894
+ login(currentPath);
895
+ }
896
+ }, [isLoading, isAuthenticated, autoLogin, login]);
897
+ if (isLoading) {
898
+ return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
899
+ }
900
+ if (!isAuthenticated) {
901
+ if (fallback) return React2.createElement(React2.Fragment, null, fallback);
902
+ return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
903
+ }
904
+ return React2.createElement(React2.Fragment, null, children);
905
+ }
906
+ function withAuth(Component) {
907
+ const displayName = Component.displayName || Component.name || "Component";
908
+ function WrappedComponent(props) {
909
+ return React2.createElement(
910
+ RequireAuth,
911
+ null,
912
+ React2.createElement(Component, props)
913
+ );
914
+ }
915
+ WrappedComponent.displayName = `withAuth(${displayName})`;
916
+ return WrappedComponent;
917
+ }
918
+
919
+ // src/react/CallbackPage.tsx
920
+ import React3, { useEffect as useEffect3, useState as useState2 } from "react";
921
+ function CallbackPage() {
922
+ const { client, refreshUser } = useSso();
923
+ const [error, setError] = useState2(null);
924
+ const [processing, setProcessing] = useState2(true);
925
+ useEffect3(() => {
926
+ let cancelled = false;
927
+ async function handleCallback() {
928
+ try {
929
+ await client.handleCallback(window.location.href);
930
+ if (cancelled) return;
931
+ await refreshUser();
932
+ const returnUrl = getReturnUrl();
933
+ removeReturnUrl();
934
+ window.location.href = returnUrl || "/";
935
+ } catch (err) {
936
+ if (cancelled) return;
937
+ if (err instanceof SsoError) {
938
+ setError(err.description || `SSO \u9519\u8BEF (${err.code})`);
939
+ } else if (err instanceof TypeError && err.message.includes("fetch")) {
940
+ setError("\u7F51\u7EDC\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5");
941
+ } else if (err instanceof Error) {
942
+ setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
943
+ } else {
944
+ setError("\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
945
+ }
946
+ setProcessing(false);
947
+ }
948
+ }
949
+ handleCallback();
950
+ return () => {
951
+ cancelled = true;
952
+ };
953
+ }, [client, refreshUser]);
954
+ if (error) {
955
+ return React3.createElement(
956
+ "div",
957
+ {
958
+ style: {
959
+ display: "flex",
960
+ flexDirection: "column",
961
+ alignItems: "center",
962
+ justifyContent: "center",
963
+ minHeight: "100vh",
964
+ fontFamily: "system-ui, sans-serif"
965
+ }
966
+ },
967
+ React3.createElement(
968
+ "p",
969
+ { style: { color: "#dc2626", marginBottom: "1rem" } },
970
+ error
971
+ ),
972
+ React3.createElement(
973
+ "a",
974
+ { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
975
+ "\u8FD4\u56DE\u9996\u9875"
976
+ )
977
+ );
978
+ }
979
+ if (processing) {
980
+ return React3.createElement(
981
+ "div",
982
+ {
983
+ style: {
984
+ display: "flex",
985
+ alignItems: "center",
986
+ justifyContent: "center",
987
+ minHeight: "100vh",
988
+ fontFamily: "system-ui, sans-serif"
989
+ }
990
+ },
991
+ React3.createElement("p", null, "\u6B63\u5728\u5904\u7406\u767B\u5F55\u56DE\u8C03...")
992
+ );
993
+ }
994
+ return null;
995
+ }
996
+ export {
997
+ CallbackPage,
998
+ RequireAuth,
999
+ SsoProvider,
1000
+ useSso,
1001
+ withAuth
1002
+ };
1003
+ //# sourceMappingURL=index.mjs.map