@nihplod/sso-sdk 1.0.1 → 1.1.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.
@@ -9,6 +9,26 @@ import React, {
9
9
  } from "react";
10
10
 
11
11
  // src/core/errors.ts
12
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
13
+ switch (oauthError) {
14
+ case "invalid_grant":
15
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
16
+ case "invalid_client":
17
+ return "client_disabled";
18
+ case "access_denied":
19
+ return "user_denied_authorization";
20
+ case "server_error":
21
+ return "sso_server_error";
22
+ case "rate_limited":
23
+ return "rate_limited";
24
+ case "invalid_scope":
25
+ case "invalid_request":
26
+ case "unauthorized_client":
27
+ case "unsupported_grant_type":
28
+ default:
29
+ return "token_request_failed";
30
+ }
31
+ }
12
32
  var SsoError = class extends Error {
13
33
  constructor(code, description, cause) {
14
34
  super(`[SSO SDK] ${code}: ${description}`);
@@ -82,6 +102,34 @@ function createMemoryStorageAdapter() {
82
102
  };
83
103
  }
84
104
  var memoryStorageAdapter = createMemoryStorageAdapter();
105
+ function createSessionStorageAdapter() {
106
+ const fallback = /* @__PURE__ */ new Map();
107
+ return {
108
+ get(key) {
109
+ if (typeof sessionStorage !== "undefined") {
110
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
111
+ }
112
+ return fallback.get(key) ?? null;
113
+ },
114
+ set(key, value) {
115
+ if (typeof sessionStorage === "undefined") {
116
+ fallback.set(key, value);
117
+ return;
118
+ }
119
+ try {
120
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
121
+ } catch {
122
+ fallback.set(key, value);
123
+ }
124
+ },
125
+ remove(key) {
126
+ fallback.delete(key);
127
+ if (typeof sessionStorage === "undefined") return;
128
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
129
+ }
130
+ };
131
+ }
132
+ var _transient = createSessionStorageAdapter();
85
133
  var _storage = memoryStorageAdapter;
86
134
  function saveTokenData(data, clientId) {
87
135
  _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
@@ -99,31 +147,31 @@ function removeTokenData(clientId) {
99
147
  _storage.remove(buildKey(TOKEN_KEY, clientId));
100
148
  }
101
149
  function savePkceVerifier(clientId, verifier) {
102
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
150
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
103
151
  }
104
152
  function getPkceVerifier(clientId) {
105
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
153
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
106
154
  }
107
155
  function removePkceVerifier(clientId) {
108
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
156
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
109
157
  }
110
158
  function saveOAuthState(state, clientId) {
111
- _storage.set(buildKey(STATE_KEY, clientId), state);
159
+ _transient.set(buildKey(STATE_KEY, clientId), state);
112
160
  }
113
161
  function getOAuthState(clientId) {
114
- return _storage.get(buildKey(STATE_KEY, clientId));
162
+ return _transient.get(buildKey(STATE_KEY, clientId));
115
163
  }
116
164
  function removeOAuthState(clientId) {
117
- _storage.remove(buildKey(STATE_KEY, clientId));
165
+ _transient.remove(buildKey(STATE_KEY, clientId));
118
166
  }
119
167
  function saveReturnUrl(url, clientId) {
120
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
168
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
121
169
  }
122
170
  function getReturnUrl(clientId) {
123
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
171
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
124
172
  }
125
173
  function removeReturnUrl(clientId) {
126
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
174
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
127
175
  }
128
176
  function clearAllSsoData(clientId) {
129
177
  if (clientId) {
@@ -131,23 +179,58 @@ function clearAllSsoData(clientId) {
131
179
  removeOAuthState(clientId);
132
180
  removeReturnUrl(clientId);
133
181
  removePkceVerifier(clientId);
182
+ removePkceVerifier(`${clientId}_popup_nonce`);
134
183
  return;
135
184
  }
136
185
  removeTokenData();
137
186
  removeOAuthState();
138
187
  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
- }
188
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
189
+ const stores = [
190
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
191
+ typeof localStorage !== "undefined" ? localStorage : null
192
+ ];
193
+ for (const store of stores) {
194
+ if (!store) continue;
195
+ const keys = [];
196
+ for (let i = 0; i < store.length; i++) {
197
+ const key = store.key(i);
198
+ if (key?.startsWith(prefix)) keys.push(key);
199
+ }
200
+ for (const key of keys) {
201
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
202
+ store.removeItem(key);
146
203
  }
147
204
  }
148
205
  }
149
206
 
150
- // src/core/SsoClient.ts
207
+ // src/core/security.ts
208
+ function isTrustedReturnUrl(url, currentOrigin) {
209
+ if (!url) return false;
210
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
211
+ try {
212
+ return new URL(url).origin === currentOrigin;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+ function timingSafeEqualString(a, b) {
218
+ const ba = new TextEncoder().encode(a);
219
+ const bb = new TextEncoder().encode(b);
220
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
221
+ const len = Math.max(ba.length, bb.length);
222
+ let diff = ba.length ^ bb.length;
223
+ for (let i = 0; i < len; i++) {
224
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
225
+ }
226
+ return diff === 0;
227
+ }
228
+
229
+ // src/core/id-token.ts
230
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
231
+ function getCrypto() {
232
+ return _crypto ?? null;
233
+ }
151
234
  function base64UrlDecode(input) {
152
235
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
153
236
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -182,14 +265,32 @@ function normalizeIssuer(url) {
182
265
  return url.replace(/\/+$/, "");
183
266
  }
184
267
  var cachedJwks = null;
268
+ var cachedDiscovery = null;
185
269
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
186
- async function fetchJwks(baseUrl) {
270
+ async function fetchDiscoveryDoc(baseUrl) {
187
271
  const now = Date.now();
188
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
272
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
273
+ return cachedDiscovery.doc;
274
+ }
275
+ try {
276
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
277
+ const doc = res.ok ? await res.json() : null;
278
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
279
+ return doc;
280
+ } catch {
281
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
282
+ return null;
283
+ }
284
+ }
285
+ async function fetchJwks(baseUrl, options = {}) {
286
+ const now = Date.now();
287
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
189
288
  return cachedJwks.jwks;
190
289
  }
290
+ const discovery = await fetchDiscoveryDoc(baseUrl);
291
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
191
292
  try {
192
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
293
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
193
294
  if (!res.ok) return null;
194
295
  const jwks = await res.json();
195
296
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -198,24 +299,13 @@ async function fetchJwks(baseUrl) {
198
299
  return null;
199
300
  }
200
301
  }
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) {
302
+ async function verifyRs256Signature(token, jwk) {
215
303
  try {
216
304
  const [headerB64, payloadB64, signature] = token.split(".");
217
305
  if (!signature || !jwk.n || !jwk.e) return false;
218
- const cryptoKey = await crypto.subtle.importKey(
306
+ const c = getCrypto();
307
+ if (!c) return false;
308
+ const cryptoKey = await c.subtle.importKey(
219
309
  "jwk",
220
310
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
221
311
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -224,7 +314,7 @@ async function verifyRs256(token, jwk) {
224
314
  );
225
315
  const signatureBytes = base64UrlDecode(signature);
226
316
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
227
- return await crypto.subtle.verify(
317
+ return await c.subtle.verify(
228
318
  "RSASSA-PKCS1-v1_5",
229
319
  cryptoKey,
230
320
  signatureBytes,
@@ -234,7 +324,20 @@ async function verifyRs256(token, jwk) {
234
324
  return false;
235
325
  }
236
326
  }
237
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
327
+ async function computeAtHash(accessToken) {
328
+ const c = getCrypto();
329
+ if (!c) return "";
330
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
331
+ const bytes = new Uint8Array(hash);
332
+ const half = bytes.slice(0, bytes.length / 2);
333
+ let binary = "";
334
+ for (const b of half) {
335
+ binary += String.fromCharCode(b);
336
+ }
337
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
338
+ }
339
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
340
+ const { rejectHs256WhenRs256Available = true } = options;
238
341
  const header = decodeJwtHeader(idToken);
239
342
  if (!header) {
240
343
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -243,54 +346,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
243
346
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
244
347
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
245
348
  }
349
+ const baseUrl = normalizeIssuer(expectedIssuer);
350
+ const discovery = await fetchDiscoveryDoc(baseUrl);
351
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
246
352
  if (alg === "RS256") {
247
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
353
+ const jwks = await fetchJwks(baseUrl);
248
354
  if (!jwks) {
249
355
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
250
356
  }
251
357
  const kid = typeof header.kid === "string" ? header.kid : void 0;
252
- const key = jwks.keys.find(
358
+ const matchCandidates = (set) => set.keys.filter(
253
359
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
254
360
  );
255
- if (!key) {
361
+ const verifyAny = async (keys) => {
362
+ for (const key of keys) {
363
+ if (await verifyRs256Signature(idToken, key)) return true;
364
+ }
365
+ return false;
366
+ };
367
+ let candidates = matchCandidates(jwks);
368
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
369
+ if (candidates.length === 0 || !validSig) {
370
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
371
+ if (freshJwks) {
372
+ candidates = matchCandidates(freshJwks);
373
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
374
+ }
375
+ }
376
+ if (candidates.length === 0) {
256
377
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
257
378
  }
258
- const validSig = await verifyRs256(idToken, key);
259
379
  if (!validSig) {
260
380
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
261
381
  }
262
382
  }
263
383
  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"
384
+ if (rejectHs256WhenRs256Available) {
385
+ const jwks = await fetchJwks(baseUrl);
386
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
387
+ if (hasRs256) {
388
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
389
+ }
390
+ }
391
+ throw new SsoError(
392
+ "id_token_hs256_unsupported",
393
+ "ID Token \u4F7F\u7528 HS256 \u5BF9\u79F0\u7B7E\u540D\uFF0CSDK \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u3002\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5728 SSO \u4E3B\u7AD9\u914D\u7F6E RS256 \u5BC6\u94A5\u5BF9\uFF08JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY\uFF09\u540E\u91CD\u65B0\u7B7E\u53D1\u3002"
266
394
  );
267
395
  }
268
396
  const payload = decodeJwtPayload(idToken);
269
397
  if (!payload) {
270
398
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
271
399
  }
272
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
273
400
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
274
- if (tokenIssuer !== normalizedExpectedIssuer) {
401
+ if (tokenIssuer !== normalizedIssuer) {
275
402
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
276
403
  }
277
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
404
+ const aud = payload.aud;
405
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
406
+ if (!audList.includes(expectedClientId)) {
278
407
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
279
408
  }
280
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
409
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
410
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
411
+ }
412
+ if (typeof payload.exp !== "number") {
413
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
414
+ }
415
+ if (Date.now() >= payload.exp * 1e3) {
281
416
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
282
417
  }
418
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
419
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
420
+ }
283
421
  if (typeof payload.sub !== "string" || !payload.sub) {
284
422
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
285
423
  }
286
424
  if (typeof payload.at_hash === "string" && payload.at_hash) {
287
425
  const actual = await computeAtHash(accessToken);
288
- if (actual !== payload.at_hash) {
426
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
289
427
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
290
428
  }
291
429
  }
292
430
  return { sub: payload.sub };
293
431
  }
432
+
433
+ // src/core/SsoClient.ts
294
434
  var _SsoClient = class _SsoClient {
295
435
  constructor(config) {
296
436
  this._discovery = null;
@@ -359,6 +499,17 @@ var _SsoClient = class _SsoClient {
359
499
  if (d) return d.userinfo_endpoint;
360
500
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
361
501
  }
502
+ /**
503
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
504
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
505
+ */
506
+ _saveReturnUrlIfTrusted(returnUrl) {
507
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
508
+ saveReturnUrl(returnUrl);
509
+ } else {
510
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
511
+ }
512
+ }
362
513
  // ============================================
363
514
  // 公共 API
364
515
  // ============================================
@@ -368,7 +519,8 @@ var _SsoClient = class _SsoClient {
368
519
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
369
520
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
370
521
  *
371
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
522
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
523
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
372
524
  */
373
525
  async login(returnUrl) {
374
526
  const verifier = generateCodeVerifier();
@@ -377,7 +529,7 @@ var _SsoClient = class _SsoClient {
377
529
  savePkceVerifier(this.config.clientId, verifier);
378
530
  saveOAuthState(state, this.config.clientId);
379
531
  if (returnUrl) {
380
- saveReturnUrl(returnUrl);
532
+ this._saveReturnUrlIfTrusted(returnUrl);
381
533
  }
382
534
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
383
535
  const params = new URLSearchParams();
@@ -401,7 +553,7 @@ var _SsoClient = class _SsoClient {
401
553
  const state = generateState();
402
554
  savePkceVerifier(this.config.clientId, verifier);
403
555
  saveOAuthState(state, this.config.clientId);
404
- if (returnUrl) saveReturnUrl(returnUrl);
556
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
405
557
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
406
558
  const params = new URLSearchParams();
407
559
  params.set("response_type", "code");
@@ -413,11 +565,140 @@ var _SsoClient = class _SsoClient {
413
565
  params.set("code_challenge_method", "S256");
414
566
  return `${authorizeEndpoint}?${params.toString()}`;
415
567
  }
568
+ /**
569
+ * 弹窗模式 SSO 登录
570
+ *
571
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
572
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
573
+ *
574
+ * 流程:
575
+ * 1. window.open() 打开授权 URL 到弹窗
576
+ * 2. 用户在弹窗中完成登录
577
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
578
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
579
+ * 5. 弹窗自动关闭
580
+ *
581
+ * @param options - 弹窗配置
582
+ * @param options.returnUrl - 登录成功后的返回地址
583
+ * @param options.width - 弹窗宽度(默认 480)
584
+ * @param options.height - 弹窗高度(默认 640)
585
+ * @returns TokenData
586
+ *
587
+ * @example
588
+ * ```typescript
589
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
590
+ * try {
591
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
592
+ * console.log("登录成功", tokenData);
593
+ * } catch (err) {
594
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
595
+ * // 弹窗被拦截,回退到同页重定向
596
+ * await client.login("/dashboard");
597
+ * }
598
+ * }
599
+ * ```
600
+ */
601
+ async loginPopup(options = {}) {
602
+ const verifier = generateCodeVerifier();
603
+ const challenge = await generateCodeChallenge(verifier);
604
+ const state = generateState();
605
+ const popupNonce = generateState();
606
+ savePkceVerifier(this.config.clientId, verifier);
607
+ saveOAuthState(state, this.config.clientId);
608
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
609
+ if (options.returnUrl) {
610
+ this._saveReturnUrlIfTrusted(options.returnUrl);
611
+ }
612
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
613
+ const params = new URLSearchParams();
614
+ params.set("response_type", "code");
615
+ params.set("client_id", this.config.clientId);
616
+ params.set("redirect_uri", this.config.redirectUri);
617
+ params.set("scope", this.config.scopes || "openid profile");
618
+ params.set("state", state);
619
+ params.set("code_challenge", challenge);
620
+ params.set("code_challenge_method", "S256");
621
+ params.set("popup_nonce", popupNonce);
622
+ const width = options.width || 480;
623
+ const height = options.height || 640;
624
+ const left = Math.max(0, (window.screen.width - width) / 2);
625
+ const top = Math.max(0, (window.screen.height - height) / 2);
626
+ const popupFeatures = [
627
+ `width=${width}`,
628
+ `height=${height}`,
629
+ `left=${Math.round(left)}`,
630
+ `top=${Math.round(top)}`,
631
+ "resizable=yes",
632
+ "scrollbars=yes",
633
+ "status=yes"
634
+ ].join(",");
635
+ const popup = window.open(
636
+ `${authorizeEndpoint}?${params.toString()}`,
637
+ "nihplod_sso_popup",
638
+ popupFeatures
639
+ );
640
+ if (!popup) {
641
+ removePkceVerifier(this.config.clientId);
642
+ removeOAuthState(this.config.clientId);
643
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
644
+ throw new SsoError(
645
+ "popup_blocked",
646
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
647
+ );
648
+ }
649
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
650
+ try {
651
+ popup.focus();
652
+ return await new Promise((resolve, reject) => {
653
+ let completed = false;
654
+ const handleMessage = (event) => {
655
+ if (completed) return;
656
+ if (event.origin !== redirectUriOrigin) return;
657
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
658
+ if (!event.data.callbackUrl) return;
659
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
660
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
661
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
662
+ completed = true;
663
+ cleanup();
664
+ if (popup && !popup.closed) {
665
+ popup.close();
666
+ }
667
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
668
+ };
669
+ const pollTimer = setInterval(() => {
670
+ if (popup.closed) {
671
+ if (!completed) {
672
+ completed = true;
673
+ cleanup();
674
+ reject(
675
+ new SsoError(
676
+ "popup_closed",
677
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
678
+ )
679
+ );
680
+ }
681
+ }
682
+ }, 500);
683
+ const cleanup = () => {
684
+ clearInterval(pollTimer);
685
+ window.removeEventListener("message", handleMessage);
686
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
687
+ };
688
+ window.addEventListener("message", handleMessage);
689
+ });
690
+ } catch (err) {
691
+ removePkceVerifier(this.config.clientId);
692
+ removeOAuthState(this.config.clientId);
693
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
694
+ throw err;
695
+ }
696
+ }
416
697
  /**
417
698
  * 处理 OAuth 回调
418
699
  *
419
700
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
420
- * 成功后 token 自动保存到 sessionStorage。
701
+ * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
421
702
  *
422
703
  * @param callbackUrl - 完整的回调 URL(window.location.href)
423
704
  * @returns TokenData 或 null
@@ -436,14 +717,14 @@ var _SsoClient = class _SsoClient {
436
717
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
437
718
  }
438
719
  const savedState = getOAuthState(this.config.clientId);
439
- if (!savedState || savedState !== returnedState) {
720
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
440
721
  removeOAuthState(this.config.clientId);
722
+ removePkceVerifier(this.config.clientId);
441
723
  throw new SsoError(
442
724
  "state_mismatch",
443
725
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
444
726
  );
445
727
  }
446
- removeOAuthState(this.config.clientId);
447
728
  const verifier = getPkceVerifier(this.config.clientId);
448
729
  if (!verifier) {
449
730
  throw new SsoError(
@@ -451,7 +732,6 @@ var _SsoClient = class _SsoClient {
451
732
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
452
733
  );
453
734
  }
454
- removePkceVerifier(this.config.clientId);
455
735
  const tokenEndpoint = await this._getTokenEndpoint();
456
736
  const body = new URLSearchParams();
457
737
  body.set("grant_type", "authorization_code");
@@ -478,19 +758,22 @@ var _SsoClient = class _SsoClient {
478
758
  errData = await res.json();
479
759
  } catch {
480
760
  }
761
+ const serverError = errData.error || "";
481
762
  throw new SsoError(
482
- "token_request_failed",
763
+ mapOAuthErrorToSsoCode(serverError),
483
764
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
484
765
  );
485
766
  }
486
767
  const data = await res.json();
768
+ removeOAuthState(this.config.clientId);
769
+ removePkceVerifier(this.config.clientId);
487
770
  if (data.id_token) {
488
771
  try {
489
772
  await validateIdToken(
490
773
  data.id_token,
774
+ data.access_token,
491
775
  this.config.ssoBaseUrl,
492
- this.config.clientId,
493
- data.access_token
776
+ this.config.clientId
494
777
  );
495
778
  } catch (err) {
496
779
  removeTokenData(this.config.clientId);
@@ -539,15 +822,24 @@ var _SsoClient = class _SsoClient {
539
822
  if (this.config.clientSecret) {
540
823
  body.set("client_secret", this.config.clientSecret);
541
824
  }
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);
825
+ let res = null;
826
+ let lastErr;
827
+ for (let attempt = 0; attempt < 2; attempt++) {
828
+ try {
829
+ res = await fetch(tokenEndpoint, {
830
+ method: "POST",
831
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
832
+ body: body.toString()
833
+ });
834
+ lastErr = null;
835
+ break;
836
+ } catch (err) {
837
+ lastErr = err;
838
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
839
+ }
840
+ }
841
+ if (lastErr || !res) {
842
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
551
843
  }
552
844
  if (!res.ok) {
553
845
  let errData = {};
@@ -560,17 +852,31 @@ var _SsoClient = class _SsoClient {
560
852
  removeTokenData(this.config.clientId);
561
853
  }
562
854
  throw new SsoError(
563
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
855
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
564
856
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
565
857
  );
566
858
  }
567
859
  const data = await res.json();
860
+ if (data.id_token) {
861
+ try {
862
+ await validateIdToken(
863
+ data.id_token,
864
+ data.access_token,
865
+ this.config.ssoBaseUrl,
866
+ this.config.clientId
867
+ );
868
+ } catch (err) {
869
+ removeTokenData(this.config.clientId);
870
+ throw err;
871
+ }
872
+ }
568
873
  const now = Date.now();
569
874
  const tokenData = {
570
875
  access_token: data.access_token,
571
876
  token_type: data.token_type,
572
877
  expires_in: data.expires_in,
573
- refresh_token: data.refresh_token,
878
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
879
+ refresh_token: data.refresh_token || current.refresh_token,
574
880
  id_token: data.id_token,
575
881
  issued_at: now,
576
882
  expires_at: now + data.expires_in * 1e3
@@ -671,13 +977,14 @@ var _SsoClient = class _SsoClient {
671
977
  }
672
978
  if (redirectToSso) {
673
979
  const discovery = await this._getDiscovery();
674
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
980
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
675
981
  const logoutUrl = new URL(endSessionEndpoint);
676
982
  logoutUrl.searchParams.set("client_id", this.config.clientId);
677
- if (this.config.redirectUri) {
983
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
984
+ if (postLogoutUri) {
678
985
  logoutUrl.searchParams.set(
679
986
  "post_logout_redirect_uri",
680
- this.config.redirectUri
987
+ postLogoutUri
681
988
  );
682
989
  }
683
990
  if (idTokenHint) {
@@ -708,6 +1015,7 @@ var SsoClient = _SsoClient;
708
1015
  var SsoContext = createContext(null);
709
1016
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
710
1017
  var LOCK_TTL_MS = 5e3;
1018
+ var ownedLocks = /* @__PURE__ */ new Map();
711
1019
  function lockKey(clientId) {
712
1020
  return REFRESH_LOCK_PREFIX + clientId;
713
1021
  }
@@ -722,12 +1030,44 @@ function acquireRefreshLock(clientId) {
722
1030
  return false;
723
1031
  }
724
1032
  }
725
- localStorage.setItem(key, String(now));
1033
+ const token = `${now}:${Math.random().toString(36).slice(2)}`;
1034
+ localStorage.setItem(key, token);
1035
+ if (localStorage.getItem(key) !== token) return false;
1036
+ ownedLocks.set(clientId, token);
726
1037
  return true;
727
1038
  }
728
1039
  function releaseRefreshLock(clientId) {
729
1040
  if (typeof localStorage === "undefined") return;
730
- localStorage.removeItem(lockKey(clientId));
1041
+ const owned = ownedLocks.get(clientId);
1042
+ ownedLocks.delete(clientId);
1043
+ if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
1044
+ localStorage.removeItem(lockKey(clientId));
1045
+ }
1046
+ }
1047
+ async function withRefreshLock(clientId, task) {
1048
+ if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
1049
+ try {
1050
+ let ran = false;
1051
+ await navigator.locks.request(
1052
+ `nihplod_sso_refresh_${clientId}`,
1053
+ { ifAvailable: true },
1054
+ async (lock) => {
1055
+ if (!lock) return;
1056
+ ran = true;
1057
+ await task();
1058
+ }
1059
+ );
1060
+ return ran;
1061
+ } catch {
1062
+ }
1063
+ }
1064
+ if (!acquireRefreshLock(clientId)) return false;
1065
+ try {
1066
+ await task();
1067
+ return true;
1068
+ } finally {
1069
+ releaseRefreshLock(clientId);
1070
+ }
731
1071
  }
732
1072
  function SsoProvider({
733
1073
  config,
@@ -738,12 +1078,12 @@ function SsoProvider({
738
1078
  const [user, setUser] = useState(null);
739
1079
  const [isLoading, setIsLoading] = useState(true);
740
1080
  const [isAuthenticated, setIsAuthenticated] = useState(false);
741
- const clientRef = useRef(new SsoClient(config));
1081
+ const [client] = useState(() => new SsoClient(config));
742
1082
  const refreshTimerRef = useRef(null);
743
1083
  const loadedRef = useRef(false);
744
- const client = clientRef.current;
745
1084
  const loadUser = useCallback(async () => {
746
- if (!client.isAuthenticated()) {
1085
+ const tokenData = getTokenData(client.config.clientId);
1086
+ if (!tokenData) {
747
1087
  setUser(null);
748
1088
  setIsAuthenticated(false);
749
1089
  setIsLoading(false);
@@ -764,14 +1104,25 @@ function SsoProvider({
764
1104
  useEffect(() => {
765
1105
  if (loadedRef.current) return;
766
1106
  loadedRef.current = true;
767
- if (client.isAuthenticated()) {
768
- loadUser();
769
- } else {
770
- setIsLoading(false);
771
- }
1107
+ Promise.resolve().then(() => {
1108
+ if (getTokenData(client.config.clientId)) {
1109
+ loadUser();
1110
+ } else {
1111
+ setIsLoading(false);
1112
+ }
1113
+ });
772
1114
  }, [client, loadUser]);
773
1115
  useEffect(() => {
774
1116
  let active = true;
1117
+ const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1118
+ try {
1119
+ const td = await client.refreshToken();
1120
+ onTokenRefreshed?.(td.access_token);
1121
+ loadUser();
1122
+ } catch {
1123
+ setTimeout(() => loadUser(), 500);
1124
+ }
1125
+ });
775
1126
  const scheduleNextRefresh = () => {
776
1127
  if (!active) return;
777
1128
  if (refreshTimerRef.current) {
@@ -782,41 +1133,23 @@ function SsoProvider({
782
1133
  if (!tokenData) return;
783
1134
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
784
1135
  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));
1136
+ void attemptRefresh();
792
1137
  return;
793
1138
  }
794
1139
  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));
1140
+ void attemptRefresh();
802
1141
  return;
803
1142
  }
804
1143
  const delayMs = (remainingSec - refreshThreshold) * 1e3;
805
1144
  refreshTimerRef.current = setTimeout(() => {
1145
+ if (!active) return;
806
1146
  const td = getTokenData(client.config.clientId);
807
1147
  if (!td) return;
808
1148
  const secLeft = (td.expires_at - Date.now()) / 1e3;
809
1149
  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();
1150
+ void attemptRefresh().then((didRefresh) => {
1151
+ if (didRefresh) scheduleNextRefresh();
1152
+ });
820
1153
  }
821
1154
  }, Math.max(delayMs, 1e3));
822
1155
  };
@@ -844,6 +1177,14 @@ function SsoProvider({
844
1177
  },
845
1178
  [client]
846
1179
  );
1180
+ const loginPopup = useCallback(
1181
+ async (options) => {
1182
+ const tokenData = await client.loginPopup(options);
1183
+ await loadUser();
1184
+ return tokenData;
1185
+ },
1186
+ [client, loadUser]
1187
+ );
847
1188
  const logout = useCallback(
848
1189
  async (redirectToSso = false) => {
849
1190
  await client.logout(redirectToSso);
@@ -863,6 +1204,7 @@ function SsoProvider({
863
1204
  isAuthenticated,
864
1205
  isLoading,
865
1206
  login,
1207
+ loginPopup,
866
1208
  logout,
867
1209
  refreshUser,
868
1210
  getAccessToken,
@@ -883,17 +1225,26 @@ import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
883
1225
  function RequireAuth({
884
1226
  children,
885
1227
  fallback,
886
- autoLogin = true
1228
+ autoLogin = true,
1229
+ usePopup = false
887
1230
  }) {
888
- const { isAuthenticated, isLoading, login } = useSso();
1231
+ const { isAuthenticated, isLoading, login, loginPopup } = useSso();
889
1232
  const loginTriggeredRef = useRef2(false);
890
1233
  useEffect2(() => {
891
1234
  if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
892
1235
  loginTriggeredRef.current = true;
893
1236
  const currentPath = window.location.pathname + window.location.search;
894
- login(currentPath);
1237
+ if (usePopup) {
1238
+ loginPopup({ returnUrl: currentPath }).catch((err) => {
1239
+ if (err.code === "popup_blocked") {
1240
+ login(currentPath);
1241
+ }
1242
+ });
1243
+ } else {
1244
+ login(currentPath);
1245
+ }
895
1246
  }
896
- }, [isLoading, isAuthenticated, autoLogin, login]);
1247
+ }, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
897
1248
  if (isLoading) {
898
1249
  return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
899
1250
  }
@@ -923,6 +1274,20 @@ function CallbackPage() {
923
1274
  const [error, setError] = useState2(null);
924
1275
  const [processing, setProcessing] = useState2(true);
925
1276
  useEffect3(() => {
1277
+ if (window.opener && !window.opener.closed) {
1278
+ const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1279
+ let targetOrigin = window.location.origin;
1280
+ try {
1281
+ targetOrigin = window.opener.location.origin;
1282
+ } catch {
1283
+ }
1284
+ window.opener.postMessage(
1285
+ { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1286
+ targetOrigin
1287
+ );
1288
+ Promise.resolve().then(() => setProcessing(false));
1289
+ return;
1290
+ }
926
1291
  let cancelled = false;
927
1292
  async function handleCallback() {
928
1293
  try {
@@ -931,7 +1296,7 @@ function CallbackPage() {
931
1296
  await refreshUser();
932
1297
  const returnUrl = getReturnUrl();
933
1298
  removeReturnUrl();
934
- window.location.href = returnUrl || "/";
1299
+ window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
935
1300
  } catch (err) {
936
1301
  if (cancelled) return;
937
1302
  if (err instanceof SsoError) {