@nihplod/sso-sdk 1.0.0 → 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.
@@ -42,6 +42,26 @@ module.exports = __toCommonJS(react_exports);
42
42
  var import_react = __toESM(require("react"));
43
43
 
44
44
  // src/core/errors.ts
45
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
46
+ switch (oauthError) {
47
+ case "invalid_grant":
48
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
49
+ case "invalid_client":
50
+ return "client_disabled";
51
+ case "access_denied":
52
+ return "user_denied_authorization";
53
+ case "server_error":
54
+ return "sso_server_error";
55
+ case "rate_limited":
56
+ return "rate_limited";
57
+ case "invalid_scope":
58
+ case "invalid_request":
59
+ case "unauthorized_client":
60
+ case "unsupported_grant_type":
61
+ default:
62
+ return "token_request_failed";
63
+ }
64
+ }
45
65
  var SsoError = class extends Error {
46
66
  constructor(code, description, cause) {
47
67
  super(`[SSO SDK] ${code}: ${description}`);
@@ -115,6 +135,34 @@ function createMemoryStorageAdapter() {
115
135
  };
116
136
  }
117
137
  var memoryStorageAdapter = createMemoryStorageAdapter();
138
+ function createSessionStorageAdapter() {
139
+ const fallback = /* @__PURE__ */ new Map();
140
+ return {
141
+ get(key) {
142
+ if (typeof sessionStorage !== "undefined") {
143
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
144
+ }
145
+ return fallback.get(key) ?? null;
146
+ },
147
+ set(key, value) {
148
+ if (typeof sessionStorage === "undefined") {
149
+ fallback.set(key, value);
150
+ return;
151
+ }
152
+ try {
153
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
154
+ } catch {
155
+ fallback.set(key, value);
156
+ }
157
+ },
158
+ remove(key) {
159
+ fallback.delete(key);
160
+ if (typeof sessionStorage === "undefined") return;
161
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
162
+ }
163
+ };
164
+ }
165
+ var _transient = createSessionStorageAdapter();
118
166
  var _storage = memoryStorageAdapter;
119
167
  function saveTokenData(data, clientId) {
120
168
  _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
@@ -132,31 +180,31 @@ function removeTokenData(clientId) {
132
180
  _storage.remove(buildKey(TOKEN_KEY, clientId));
133
181
  }
134
182
  function savePkceVerifier(clientId, verifier) {
135
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
183
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
136
184
  }
137
185
  function getPkceVerifier(clientId) {
138
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
186
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
139
187
  }
140
188
  function removePkceVerifier(clientId) {
141
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
189
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
142
190
  }
143
191
  function saveOAuthState(state, clientId) {
144
- _storage.set(buildKey(STATE_KEY, clientId), state);
192
+ _transient.set(buildKey(STATE_KEY, clientId), state);
145
193
  }
146
194
  function getOAuthState(clientId) {
147
- return _storage.get(buildKey(STATE_KEY, clientId));
195
+ return _transient.get(buildKey(STATE_KEY, clientId));
148
196
  }
149
197
  function removeOAuthState(clientId) {
150
- _storage.remove(buildKey(STATE_KEY, clientId));
198
+ _transient.remove(buildKey(STATE_KEY, clientId));
151
199
  }
152
200
  function saveReturnUrl(url, clientId) {
153
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
201
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
154
202
  }
155
203
  function getReturnUrl(clientId) {
156
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
204
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
157
205
  }
158
206
  function removeReturnUrl(clientId) {
159
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
207
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
160
208
  }
161
209
  function clearAllSsoData(clientId) {
162
210
  if (clientId) {
@@ -164,23 +212,58 @@ function clearAllSsoData(clientId) {
164
212
  removeOAuthState(clientId);
165
213
  removeReturnUrl(clientId);
166
214
  removePkceVerifier(clientId);
215
+ removePkceVerifier(`${clientId}_popup_nonce`);
167
216
  return;
168
217
  }
169
218
  removeTokenData();
170
219
  removeOAuthState();
171
220
  removeReturnUrl();
172
- if (typeof localStorage !== "undefined") {
173
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
174
- for (let i = 0; i < localStorage.length; i++) {
175
- const key = localStorage.key(i);
176
- if (key?.startsWith(prefix)) {
177
- _storage.remove(key.slice(STORAGE_PREFIX.length));
178
- }
221
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
222
+ const stores = [
223
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
224
+ typeof localStorage !== "undefined" ? localStorage : null
225
+ ];
226
+ for (const store of stores) {
227
+ if (!store) continue;
228
+ const keys = [];
229
+ for (let i = 0; i < store.length; i++) {
230
+ const key = store.key(i);
231
+ if (key?.startsWith(prefix)) keys.push(key);
232
+ }
233
+ for (const key of keys) {
234
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
235
+ store.removeItem(key);
179
236
  }
180
237
  }
181
238
  }
182
239
 
183
- // src/core/SsoClient.ts
240
+ // src/core/security.ts
241
+ function isTrustedReturnUrl(url, currentOrigin) {
242
+ if (!url) return false;
243
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
244
+ try {
245
+ return new URL(url).origin === currentOrigin;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+ function timingSafeEqualString(a, b) {
251
+ const ba = new TextEncoder().encode(a);
252
+ const bb = new TextEncoder().encode(b);
253
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
254
+ const len = Math.max(ba.length, bb.length);
255
+ let diff = ba.length ^ bb.length;
256
+ for (let i = 0; i < len; i++) {
257
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
258
+ }
259
+ return diff === 0;
260
+ }
261
+
262
+ // src/core/id-token.ts
263
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
264
+ function getCrypto() {
265
+ return _crypto ?? null;
266
+ }
184
267
  function base64UrlDecode(input) {
185
268
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
186
269
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -215,14 +298,32 @@ function normalizeIssuer(url) {
215
298
  return url.replace(/\/+$/, "");
216
299
  }
217
300
  var cachedJwks = null;
301
+ var cachedDiscovery = null;
218
302
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
219
- async function fetchJwks(baseUrl) {
303
+ async function fetchDiscoveryDoc(baseUrl) {
220
304
  const now = Date.now();
221
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
305
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
306
+ return cachedDiscovery.doc;
307
+ }
308
+ try {
309
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
310
+ const doc = res.ok ? await res.json() : null;
311
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
312
+ return doc;
313
+ } catch {
314
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
315
+ return null;
316
+ }
317
+ }
318
+ async function fetchJwks(baseUrl, options = {}) {
319
+ const now = Date.now();
320
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
222
321
  return cachedJwks.jwks;
223
322
  }
323
+ const discovery = await fetchDiscoveryDoc(baseUrl);
324
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
224
325
  try {
225
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
326
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
226
327
  if (!res.ok) return null;
227
328
  const jwks = await res.json();
228
329
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -231,24 +332,13 @@ async function fetchJwks(baseUrl) {
231
332
  return null;
232
333
  }
233
334
  }
234
- async function computeAtHash(accessToken) {
235
- const hash = await crypto.subtle.digest(
236
- "SHA-256",
237
- new TextEncoder().encode(accessToken)
238
- );
239
- const bytes = new Uint8Array(hash);
240
- const half = bytes.slice(0, bytes.length / 2);
241
- let binary = "";
242
- for (const b of half) {
243
- binary += String.fromCharCode(b);
244
- }
245
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
246
- }
247
- async function verifyRs256(token, jwk) {
335
+ async function verifyRs256Signature(token, jwk) {
248
336
  try {
249
337
  const [headerB64, payloadB64, signature] = token.split(".");
250
338
  if (!signature || !jwk.n || !jwk.e) return false;
251
- const cryptoKey = await crypto.subtle.importKey(
339
+ const c = getCrypto();
340
+ if (!c) return false;
341
+ const cryptoKey = await c.subtle.importKey(
252
342
  "jwk",
253
343
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
254
344
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -257,7 +347,7 @@ async function verifyRs256(token, jwk) {
257
347
  );
258
348
  const signatureBytes = base64UrlDecode(signature);
259
349
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
260
- return await crypto.subtle.verify(
350
+ return await c.subtle.verify(
261
351
  "RSASSA-PKCS1-v1_5",
262
352
  cryptoKey,
263
353
  signatureBytes,
@@ -267,7 +357,20 @@ async function verifyRs256(token, jwk) {
267
357
  return false;
268
358
  }
269
359
  }
270
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
360
+ async function computeAtHash(accessToken) {
361
+ const c = getCrypto();
362
+ if (!c) return "";
363
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
364
+ const bytes = new Uint8Array(hash);
365
+ const half = bytes.slice(0, bytes.length / 2);
366
+ let binary = "";
367
+ for (const b of half) {
368
+ binary += String.fromCharCode(b);
369
+ }
370
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
371
+ }
372
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
373
+ const { rejectHs256WhenRs256Available = true } = options;
271
374
  const header = decodeJwtHeader(idToken);
272
375
  if (!header) {
273
376
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -276,54 +379,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
276
379
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
277
380
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
278
381
  }
382
+ const baseUrl = normalizeIssuer(expectedIssuer);
383
+ const discovery = await fetchDiscoveryDoc(baseUrl);
384
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
279
385
  if (alg === "RS256") {
280
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
386
+ const jwks = await fetchJwks(baseUrl);
281
387
  if (!jwks) {
282
388
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
283
389
  }
284
390
  const kid = typeof header.kid === "string" ? header.kid : void 0;
285
- const key = jwks.keys.find(
391
+ const matchCandidates = (set) => set.keys.filter(
286
392
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
287
393
  );
288
- if (!key) {
394
+ const verifyAny = async (keys) => {
395
+ for (const key of keys) {
396
+ if (await verifyRs256Signature(idToken, key)) return true;
397
+ }
398
+ return false;
399
+ };
400
+ let candidates = matchCandidates(jwks);
401
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
402
+ if (candidates.length === 0 || !validSig) {
403
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
404
+ if (freshJwks) {
405
+ candidates = matchCandidates(freshJwks);
406
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
407
+ }
408
+ }
409
+ if (candidates.length === 0) {
289
410
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
290
411
  }
291
- const validSig = await verifyRs256(idToken, key);
292
412
  if (!validSig) {
293
413
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
294
414
  }
295
415
  }
296
416
  if (alg === "HS256") {
297
- console.warn(
298
- "[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"
417
+ if (rejectHs256WhenRs256Available) {
418
+ const jwks = await fetchJwks(baseUrl);
419
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
420
+ if (hasRs256) {
421
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
422
+ }
423
+ }
424
+ throw new SsoError(
425
+ "id_token_hs256_unsupported",
426
+ "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"
299
427
  );
300
428
  }
301
429
  const payload = decodeJwtPayload(idToken);
302
430
  if (!payload) {
303
431
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
304
432
  }
305
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
306
433
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
307
- if (tokenIssuer !== normalizedExpectedIssuer) {
434
+ if (tokenIssuer !== normalizedIssuer) {
308
435
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
309
436
  }
310
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
437
+ const aud = payload.aud;
438
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
439
+ if (!audList.includes(expectedClientId)) {
311
440
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
312
441
  }
313
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
442
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
443
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
444
+ }
445
+ if (typeof payload.exp !== "number") {
446
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
447
+ }
448
+ if (Date.now() >= payload.exp * 1e3) {
314
449
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
315
450
  }
451
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
452
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
453
+ }
316
454
  if (typeof payload.sub !== "string" || !payload.sub) {
317
455
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
318
456
  }
319
457
  if (typeof payload.at_hash === "string" && payload.at_hash) {
320
458
  const actual = await computeAtHash(accessToken);
321
- if (actual !== payload.at_hash) {
459
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
322
460
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
323
461
  }
324
462
  }
325
463
  return { sub: payload.sub };
326
464
  }
465
+
466
+ // src/core/SsoClient.ts
327
467
  var _SsoClient = class _SsoClient {
328
468
  constructor(config) {
329
469
  this._discovery = null;
@@ -392,6 +532,17 @@ var _SsoClient = class _SsoClient {
392
532
  if (d) return d.userinfo_endpoint;
393
533
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
394
534
  }
535
+ /**
536
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
537
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
538
+ */
539
+ _saveReturnUrlIfTrusted(returnUrl) {
540
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
541
+ saveReturnUrl(returnUrl);
542
+ } else {
543
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
544
+ }
545
+ }
395
546
  // ============================================
396
547
  // 公共 API
397
548
  // ============================================
@@ -401,7 +552,8 @@ var _SsoClient = class _SsoClient {
401
552
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
402
553
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
403
554
  *
404
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
555
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
556
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
405
557
  */
406
558
  async login(returnUrl) {
407
559
  const verifier = generateCodeVerifier();
@@ -410,7 +562,7 @@ var _SsoClient = class _SsoClient {
410
562
  savePkceVerifier(this.config.clientId, verifier);
411
563
  saveOAuthState(state, this.config.clientId);
412
564
  if (returnUrl) {
413
- saveReturnUrl(returnUrl);
565
+ this._saveReturnUrlIfTrusted(returnUrl);
414
566
  }
415
567
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
416
568
  const params = new URLSearchParams();
@@ -434,7 +586,7 @@ var _SsoClient = class _SsoClient {
434
586
  const state = generateState();
435
587
  savePkceVerifier(this.config.clientId, verifier);
436
588
  saveOAuthState(state, this.config.clientId);
437
- if (returnUrl) saveReturnUrl(returnUrl);
589
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
438
590
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
439
591
  const params = new URLSearchParams();
440
592
  params.set("response_type", "code");
@@ -446,11 +598,140 @@ var _SsoClient = class _SsoClient {
446
598
  params.set("code_challenge_method", "S256");
447
599
  return `${authorizeEndpoint}?${params.toString()}`;
448
600
  }
601
+ /**
602
+ * 弹窗模式 SSO 登录
603
+ *
604
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
605
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
606
+ *
607
+ * 流程:
608
+ * 1. window.open() 打开授权 URL 到弹窗
609
+ * 2. 用户在弹窗中完成登录
610
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
611
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
612
+ * 5. 弹窗自动关闭
613
+ *
614
+ * @param options - 弹窗配置
615
+ * @param options.returnUrl - 登录成功后的返回地址
616
+ * @param options.width - 弹窗宽度(默认 480)
617
+ * @param options.height - 弹窗高度(默认 640)
618
+ * @returns TokenData
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
623
+ * try {
624
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
625
+ * console.log("登录成功", tokenData);
626
+ * } catch (err) {
627
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
628
+ * // 弹窗被拦截,回退到同页重定向
629
+ * await client.login("/dashboard");
630
+ * }
631
+ * }
632
+ * ```
633
+ */
634
+ async loginPopup(options = {}) {
635
+ const verifier = generateCodeVerifier();
636
+ const challenge = await generateCodeChallenge(verifier);
637
+ const state = generateState();
638
+ const popupNonce = generateState();
639
+ savePkceVerifier(this.config.clientId, verifier);
640
+ saveOAuthState(state, this.config.clientId);
641
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
642
+ if (options.returnUrl) {
643
+ this._saveReturnUrlIfTrusted(options.returnUrl);
644
+ }
645
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
646
+ const params = new URLSearchParams();
647
+ params.set("response_type", "code");
648
+ params.set("client_id", this.config.clientId);
649
+ params.set("redirect_uri", this.config.redirectUri);
650
+ params.set("scope", this.config.scopes || "openid profile");
651
+ params.set("state", state);
652
+ params.set("code_challenge", challenge);
653
+ params.set("code_challenge_method", "S256");
654
+ params.set("popup_nonce", popupNonce);
655
+ const width = options.width || 480;
656
+ const height = options.height || 640;
657
+ const left = Math.max(0, (window.screen.width - width) / 2);
658
+ const top = Math.max(0, (window.screen.height - height) / 2);
659
+ const popupFeatures = [
660
+ `width=${width}`,
661
+ `height=${height}`,
662
+ `left=${Math.round(left)}`,
663
+ `top=${Math.round(top)}`,
664
+ "resizable=yes",
665
+ "scrollbars=yes",
666
+ "status=yes"
667
+ ].join(",");
668
+ const popup = window.open(
669
+ `${authorizeEndpoint}?${params.toString()}`,
670
+ "nihplod_sso_popup",
671
+ popupFeatures
672
+ );
673
+ if (!popup) {
674
+ removePkceVerifier(this.config.clientId);
675
+ removeOAuthState(this.config.clientId);
676
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
677
+ throw new SsoError(
678
+ "popup_blocked",
679
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
680
+ );
681
+ }
682
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
683
+ try {
684
+ popup.focus();
685
+ return await new Promise((resolve, reject) => {
686
+ let completed = false;
687
+ const handleMessage = (event) => {
688
+ if (completed) return;
689
+ if (event.origin !== redirectUriOrigin) return;
690
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
691
+ if (!event.data.callbackUrl) return;
692
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
693
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
694
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
695
+ completed = true;
696
+ cleanup();
697
+ if (popup && !popup.closed) {
698
+ popup.close();
699
+ }
700
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
701
+ };
702
+ const pollTimer = setInterval(() => {
703
+ if (popup.closed) {
704
+ if (!completed) {
705
+ completed = true;
706
+ cleanup();
707
+ reject(
708
+ new SsoError(
709
+ "popup_closed",
710
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
711
+ )
712
+ );
713
+ }
714
+ }
715
+ }, 500);
716
+ const cleanup = () => {
717
+ clearInterval(pollTimer);
718
+ window.removeEventListener("message", handleMessage);
719
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
720
+ };
721
+ window.addEventListener("message", handleMessage);
722
+ });
723
+ } catch (err) {
724
+ removePkceVerifier(this.config.clientId);
725
+ removeOAuthState(this.config.clientId);
726
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
727
+ throw err;
728
+ }
729
+ }
449
730
  /**
450
731
  * 处理 OAuth 回调
451
732
  *
452
733
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
453
- * 成功后 token 自动保存到 sessionStorage。
734
+ * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
454
735
  *
455
736
  * @param callbackUrl - 完整的回调 URL(window.location.href)
456
737
  * @returns TokenData 或 null
@@ -469,14 +750,14 @@ var _SsoClient = class _SsoClient {
469
750
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
470
751
  }
471
752
  const savedState = getOAuthState(this.config.clientId);
472
- if (!savedState || savedState !== returnedState) {
753
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
473
754
  removeOAuthState(this.config.clientId);
755
+ removePkceVerifier(this.config.clientId);
474
756
  throw new SsoError(
475
757
  "state_mismatch",
476
758
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
477
759
  );
478
760
  }
479
- removeOAuthState(this.config.clientId);
480
761
  const verifier = getPkceVerifier(this.config.clientId);
481
762
  if (!verifier) {
482
763
  throw new SsoError(
@@ -484,7 +765,6 @@ var _SsoClient = class _SsoClient {
484
765
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
485
766
  );
486
767
  }
487
- removePkceVerifier(this.config.clientId);
488
768
  const tokenEndpoint = await this._getTokenEndpoint();
489
769
  const body = new URLSearchParams();
490
770
  body.set("grant_type", "authorization_code");
@@ -511,19 +791,22 @@ var _SsoClient = class _SsoClient {
511
791
  errData = await res.json();
512
792
  } catch {
513
793
  }
794
+ const serverError = errData.error || "";
514
795
  throw new SsoError(
515
- "token_request_failed",
796
+ mapOAuthErrorToSsoCode(serverError),
516
797
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
517
798
  );
518
799
  }
519
800
  const data = await res.json();
801
+ removeOAuthState(this.config.clientId);
802
+ removePkceVerifier(this.config.clientId);
520
803
  if (data.id_token) {
521
804
  try {
522
805
  await validateIdToken(
523
806
  data.id_token,
807
+ data.access_token,
524
808
  this.config.ssoBaseUrl,
525
- this.config.clientId,
526
- data.access_token
809
+ this.config.clientId
527
810
  );
528
811
  } catch (err) {
529
812
  removeTokenData(this.config.clientId);
@@ -572,15 +855,24 @@ var _SsoClient = class _SsoClient {
572
855
  if (this.config.clientSecret) {
573
856
  body.set("client_secret", this.config.clientSecret);
574
857
  }
575
- let res;
576
- try {
577
- res = await fetch(tokenEndpoint, {
578
- method: "POST",
579
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
580
- body: body.toString()
581
- });
582
- } catch (err) {
583
- throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
858
+ let res = null;
859
+ let lastErr;
860
+ for (let attempt = 0; attempt < 2; attempt++) {
861
+ try {
862
+ res = await fetch(tokenEndpoint, {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
865
+ body: body.toString()
866
+ });
867
+ lastErr = null;
868
+ break;
869
+ } catch (err) {
870
+ lastErr = err;
871
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
872
+ }
873
+ }
874
+ if (lastErr || !res) {
875
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
584
876
  }
585
877
  if (!res.ok) {
586
878
  let errData = {};
@@ -593,17 +885,31 @@ var _SsoClient = class _SsoClient {
593
885
  removeTokenData(this.config.clientId);
594
886
  }
595
887
  throw new SsoError(
596
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
888
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
597
889
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
598
890
  );
599
891
  }
600
892
  const data = await res.json();
893
+ if (data.id_token) {
894
+ try {
895
+ await validateIdToken(
896
+ data.id_token,
897
+ data.access_token,
898
+ this.config.ssoBaseUrl,
899
+ this.config.clientId
900
+ );
901
+ } catch (err) {
902
+ removeTokenData(this.config.clientId);
903
+ throw err;
904
+ }
905
+ }
601
906
  const now = Date.now();
602
907
  const tokenData = {
603
908
  access_token: data.access_token,
604
909
  token_type: data.token_type,
605
910
  expires_in: data.expires_in,
606
- refresh_token: data.refresh_token,
911
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
912
+ refresh_token: data.refresh_token || current.refresh_token,
607
913
  id_token: data.id_token,
608
914
  issued_at: now,
609
915
  expires_at: now + data.expires_in * 1e3
@@ -704,13 +1010,14 @@ var _SsoClient = class _SsoClient {
704
1010
  }
705
1011
  if (redirectToSso) {
706
1012
  const discovery = await this._getDiscovery();
707
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1013
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
708
1014
  const logoutUrl = new URL(endSessionEndpoint);
709
1015
  logoutUrl.searchParams.set("client_id", this.config.clientId);
710
- if (this.config.redirectUri) {
1016
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1017
+ if (postLogoutUri) {
711
1018
  logoutUrl.searchParams.set(
712
1019
  "post_logout_redirect_uri",
713
- this.config.redirectUri
1020
+ postLogoutUri
714
1021
  );
715
1022
  }
716
1023
  if (idTokenHint) {
@@ -741,6 +1048,7 @@ var SsoClient = _SsoClient;
741
1048
  var SsoContext = (0, import_react.createContext)(null);
742
1049
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
743
1050
  var LOCK_TTL_MS = 5e3;
1051
+ var ownedLocks = /* @__PURE__ */ new Map();
744
1052
  function lockKey(clientId) {
745
1053
  return REFRESH_LOCK_PREFIX + clientId;
746
1054
  }
@@ -755,12 +1063,44 @@ function acquireRefreshLock(clientId) {
755
1063
  return false;
756
1064
  }
757
1065
  }
758
- localStorage.setItem(key, String(now));
1066
+ const token = `${now}:${Math.random().toString(36).slice(2)}`;
1067
+ localStorage.setItem(key, token);
1068
+ if (localStorage.getItem(key) !== token) return false;
1069
+ ownedLocks.set(clientId, token);
759
1070
  return true;
760
1071
  }
761
1072
  function releaseRefreshLock(clientId) {
762
1073
  if (typeof localStorage === "undefined") return;
763
- localStorage.removeItem(lockKey(clientId));
1074
+ const owned = ownedLocks.get(clientId);
1075
+ ownedLocks.delete(clientId);
1076
+ if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
1077
+ localStorage.removeItem(lockKey(clientId));
1078
+ }
1079
+ }
1080
+ async function withRefreshLock(clientId, task) {
1081
+ if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
1082
+ try {
1083
+ let ran = false;
1084
+ await navigator.locks.request(
1085
+ `nihplod_sso_refresh_${clientId}`,
1086
+ { ifAvailable: true },
1087
+ async (lock) => {
1088
+ if (!lock) return;
1089
+ ran = true;
1090
+ await task();
1091
+ }
1092
+ );
1093
+ return ran;
1094
+ } catch {
1095
+ }
1096
+ }
1097
+ if (!acquireRefreshLock(clientId)) return false;
1098
+ try {
1099
+ await task();
1100
+ return true;
1101
+ } finally {
1102
+ releaseRefreshLock(clientId);
1103
+ }
764
1104
  }
765
1105
  function SsoProvider({
766
1106
  config,
@@ -771,12 +1111,12 @@ function SsoProvider({
771
1111
  const [user, setUser] = (0, import_react.useState)(null);
772
1112
  const [isLoading, setIsLoading] = (0, import_react.useState)(true);
773
1113
  const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
774
- const clientRef = (0, import_react.useRef)(new SsoClient(config));
1114
+ const [client] = (0, import_react.useState)(() => new SsoClient(config));
775
1115
  const refreshTimerRef = (0, import_react.useRef)(null);
776
1116
  const loadedRef = (0, import_react.useRef)(false);
777
- const client = clientRef.current;
778
1117
  const loadUser = (0, import_react.useCallback)(async () => {
779
- if (!client.isAuthenticated()) {
1118
+ const tokenData = getTokenData(client.config.clientId);
1119
+ if (!tokenData) {
780
1120
  setUser(null);
781
1121
  setIsAuthenticated(false);
782
1122
  setIsLoading(false);
@@ -797,14 +1137,25 @@ function SsoProvider({
797
1137
  (0, import_react.useEffect)(() => {
798
1138
  if (loadedRef.current) return;
799
1139
  loadedRef.current = true;
800
- if (client.isAuthenticated()) {
801
- loadUser();
802
- } else {
803
- setIsLoading(false);
804
- }
1140
+ Promise.resolve().then(() => {
1141
+ if (getTokenData(client.config.clientId)) {
1142
+ loadUser();
1143
+ } else {
1144
+ setIsLoading(false);
1145
+ }
1146
+ });
805
1147
  }, [client, loadUser]);
806
1148
  (0, import_react.useEffect)(() => {
807
1149
  let active = true;
1150
+ const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1151
+ try {
1152
+ const td = await client.refreshToken();
1153
+ onTokenRefreshed?.(td.access_token);
1154
+ loadUser();
1155
+ } catch {
1156
+ setTimeout(() => loadUser(), 500);
1157
+ }
1158
+ });
808
1159
  const scheduleNextRefresh = () => {
809
1160
  if (!active) return;
810
1161
  if (refreshTimerRef.current) {
@@ -815,41 +1166,23 @@ function SsoProvider({
815
1166
  if (!tokenData) return;
816
1167
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
817
1168
  if (remainingSec <= 0) {
818
- if (!acquireRefreshLock(client.config.clientId)) return;
819
- client.refreshToken().then((td) => {
820
- onTokenRefreshed?.(td.access_token);
821
- loadUser();
822
- }).catch(() => {
823
- setTimeout(() => loadUser(), 500);
824
- }).finally(() => releaseRefreshLock(client.config.clientId));
1169
+ void attemptRefresh();
825
1170
  return;
826
1171
  }
827
1172
  if (remainingSec <= refreshThreshold) {
828
- if (!acquireRefreshLock(client.config.clientId)) return;
829
- client.refreshToken().then((td) => {
830
- onTokenRefreshed?.(td.access_token);
831
- loadUser();
832
- }).catch(() => {
833
- setTimeout(() => loadUser(), 500);
834
- }).finally(() => releaseRefreshLock(client.config.clientId));
1173
+ void attemptRefresh();
835
1174
  return;
836
1175
  }
837
1176
  const delayMs = (remainingSec - refreshThreshold) * 1e3;
838
1177
  refreshTimerRef.current = setTimeout(() => {
1178
+ if (!active) return;
839
1179
  const td = getTokenData(client.config.clientId);
840
1180
  if (!td) return;
841
1181
  const secLeft = (td.expires_at - Date.now()) / 1e3;
842
1182
  if (secLeft <= refreshThreshold) {
843
- if (!acquireRefreshLock(client.config.clientId)) {
844
- return;
845
- }
846
- client.refreshToken().then((newTd) => {
847
- onTokenRefreshed?.(newTd.access_token);
848
- loadUser();
849
- }).catch(() => {
850
- setTimeout(() => loadUser(), 500);
851
- }).finally(() => releaseRefreshLock(client.config.clientId));
852
- scheduleNextRefresh();
1183
+ void attemptRefresh().then((didRefresh) => {
1184
+ if (didRefresh) scheduleNextRefresh();
1185
+ });
853
1186
  }
854
1187
  }, Math.max(delayMs, 1e3));
855
1188
  };
@@ -877,6 +1210,14 @@ function SsoProvider({
877
1210
  },
878
1211
  [client]
879
1212
  );
1213
+ const loginPopup = (0, import_react.useCallback)(
1214
+ async (options) => {
1215
+ const tokenData = await client.loginPopup(options);
1216
+ await loadUser();
1217
+ return tokenData;
1218
+ },
1219
+ [client, loadUser]
1220
+ );
880
1221
  const logout = (0, import_react.useCallback)(
881
1222
  async (redirectToSso = false) => {
882
1223
  await client.logout(redirectToSso);
@@ -896,6 +1237,7 @@ function SsoProvider({
896
1237
  isAuthenticated,
897
1238
  isLoading,
898
1239
  login,
1240
+ loginPopup,
899
1241
  logout,
900
1242
  refreshUser,
901
1243
  getAccessToken,
@@ -916,17 +1258,26 @@ var import_react2 = __toESM(require("react"));
916
1258
  function RequireAuth({
917
1259
  children,
918
1260
  fallback,
919
- autoLogin = true
1261
+ autoLogin = true,
1262
+ usePopup = false
920
1263
  }) {
921
- const { isAuthenticated, isLoading, login } = useSso();
1264
+ const { isAuthenticated, isLoading, login, loginPopup } = useSso();
922
1265
  const loginTriggeredRef = (0, import_react2.useRef)(false);
923
1266
  (0, import_react2.useEffect)(() => {
924
1267
  if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
925
1268
  loginTriggeredRef.current = true;
926
1269
  const currentPath = window.location.pathname + window.location.search;
927
- login(currentPath);
1270
+ if (usePopup) {
1271
+ loginPopup({ returnUrl: currentPath }).catch((err) => {
1272
+ if (err.code === "popup_blocked") {
1273
+ login(currentPath);
1274
+ }
1275
+ });
1276
+ } else {
1277
+ login(currentPath);
1278
+ }
928
1279
  }
929
- }, [isLoading, isAuthenticated, autoLogin, login]);
1280
+ }, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
930
1281
  if (isLoading) {
931
1282
  return fallback || import_react2.default.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
932
1283
  }
@@ -956,6 +1307,20 @@ function CallbackPage() {
956
1307
  const [error, setError] = (0, import_react3.useState)(null);
957
1308
  const [processing, setProcessing] = (0, import_react3.useState)(true);
958
1309
  (0, import_react3.useEffect)(() => {
1310
+ if (window.opener && !window.opener.closed) {
1311
+ const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1312
+ let targetOrigin = window.location.origin;
1313
+ try {
1314
+ targetOrigin = window.opener.location.origin;
1315
+ } catch {
1316
+ }
1317
+ window.opener.postMessage(
1318
+ { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1319
+ targetOrigin
1320
+ );
1321
+ Promise.resolve().then(() => setProcessing(false));
1322
+ return;
1323
+ }
959
1324
  let cancelled = false;
960
1325
  async function handleCallback() {
961
1326
  try {
@@ -964,7 +1329,7 @@ function CallbackPage() {
964
1329
  await refreshUser();
965
1330
  const returnUrl = getReturnUrl();
966
1331
  removeReturnUrl();
967
- window.location.href = returnUrl || "/";
1332
+ window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
968
1333
  } catch (err) {
969
1334
  if (cancelled) return;
970
1335
  if (err instanceof SsoError) {