@nihplod/sso-sdk 1.5.0 → 1.6.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.
@@ -505,7 +505,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
505
505
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
506
506
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
507
507
  }
508
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
508
+ if (typeof payload.iat !== "number") {
509
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
510
+ }
511
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
509
512
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
510
513
  }
511
514
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -522,13 +525,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
522
525
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
523
526
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
524
527
  }
528
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
529
+ throw new SsoError(
530
+ "id_token_nonce_mismatch",
531
+ "ID Token \u643A\u5E26 nonce \u4F46\u672C\u5730\u65E0\u671F\u671B\u503C\uFF08\u53EF\u80FD\u975E\u672C\u6B21\u767B\u5F55\u4F1A\u8BDD\u7B7E\u53D1\uFF09"
532
+ );
525
533
  }
526
534
  return { sub: payload.sub };
527
535
  }
528
536
 
529
- // src/core/SsoClient.ts
537
+ // src/core/http.ts
530
538
  var REQUEST_TIMEOUT_MS = 1e4;
531
- var REVOKE_TIMEOUT_MS = 3e3;
532
539
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
533
540
  const controller = new AbortController();
534
541
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -538,6 +545,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
538
545
  clearTimeout(timer);
539
546
  }
540
547
  }
548
+
549
+ // src/core/SsoClient.ts
550
+ var REVOKE_TIMEOUT_MS = 3e3;
551
+ var EXPIRY_SKEW_MS = 1e4;
541
552
  function assertValidTokenResponse(data, requireRefreshToken) {
542
553
  if (typeof data.access_token !== "string" || !data.access_token) {
543
554
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -565,6 +576,8 @@ var _SsoClient = class _SsoClient {
565
576
  this._discovery = null;
566
577
  this._discoveryFetchedAt = 0;
567
578
  this._refreshLock = null;
579
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
580
+ this._discoveryOriginWarned = false;
568
581
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
569
582
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
570
583
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -588,6 +601,52 @@ var _SsoClient = class _SsoClient {
588
601
  // ============================================
589
602
  // 内部方法
590
603
  // ============================================
604
+ /**
605
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
606
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
607
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
608
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
609
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
610
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
611
+ */
612
+ _sanitizeDiscovery(d) {
613
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
614
+ const allowedOrigins = /* @__PURE__ */ new Set();
615
+ try {
616
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
617
+ allowedOrigins.add(new URL(this._serverBase).origin);
618
+ } catch {
619
+ return null;
620
+ }
621
+ const sameOrigin = (endpoint) => {
622
+ try {
623
+ return allowedOrigins.has(new URL(endpoint).origin);
624
+ } catch {
625
+ return false;
626
+ }
627
+ };
628
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
629
+ let authorizationOk = false;
630
+ try {
631
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
632
+ } catch {
633
+ authorizationOk = false;
634
+ }
635
+ const bad = [];
636
+ if (!authorizationOk) bad.push("authorization_endpoint");
637
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
638
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
639
+ if (bad.length > 0) {
640
+ if (!this._discoveryOriginWarned) {
641
+ this._discoveryOriginWarned = true;
642
+ console.warn(
643
+ `[SSO SDK] OIDC Discovery \u7AEF\u70B9 origin \u4E0E ssoBaseUrl/serverBaseUrl \u4E0D\u5339\u914D\uFF08${bad.join(", ")}\uFF09\uFF0C\u5DF2\u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9\u3002\u82E5 SSO \u4E2D\u5FC3\u786E\u5B9E\u6258\u7BA1\u8DE8\u6E90\u7AEF\u70B9\uFF0C\u8BF7\u8BBE\u7F6E allowCrossOriginDiscoveryEndpoints: true`
644
+ );
645
+ }
646
+ return null;
647
+ }
648
+ return d;
649
+ }
591
650
  /**
592
651
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
593
652
  *
@@ -614,7 +673,10 @@ var _SsoClient = class _SsoClient {
614
673
  );
615
674
  return null;
616
675
  }
617
- this._discovery = await res.json();
676
+ const doc = await res.json();
677
+ const sanitized = this._sanitizeDiscovery(doc);
678
+ if (!sanitized) return null;
679
+ this._discovery = sanitized;
618
680
  this._discoveryFetchedAt = now;
619
681
  return this._discovery;
620
682
  } catch (err) {
@@ -974,6 +1036,10 @@ var _SsoClient = class _SsoClient {
974
1036
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
975
1037
  }
976
1038
  if (!res.ok) {
1039
+ removeOAuthState(this.config.clientId);
1040
+ removeOAuthNonce(this.config.clientId);
1041
+ removePkceVerifier(this.config.clientId);
1042
+ removeSilentProbe(this.config.clientId);
977
1043
  let errData = {};
978
1044
  try {
979
1045
  errData = await res.json();
@@ -989,6 +1055,10 @@ var _SsoClient = class _SsoClient {
989
1055
  assertValidTokenResponse(data, true);
990
1056
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
991
1057
  if (requestedScopes.includes("openid") && !data.id_token) {
1058
+ removeOAuthState(this.config.clientId);
1059
+ removeOAuthNonce(this.config.clientId);
1060
+ removePkceVerifier(this.config.clientId);
1061
+ removeSilentProbe(this.config.clientId);
992
1062
  removeTokenData(this.config.clientId);
993
1063
  throw new SsoError(
994
1064
  "id_token_invalid",
@@ -1133,7 +1203,7 @@ var _SsoClient = class _SsoClient {
1133
1203
  if (!tokenData) {
1134
1204
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1135
1205
  }
1136
- if (Date.now() >= tokenData.expires_at) {
1206
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1137
1207
  tokenData = await this.refreshToken();
1138
1208
  }
1139
1209
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1168,7 +1238,7 @@ var _SsoClient = class _SsoClient {
1168
1238
  async getAccessToken() {
1169
1239
  let tokenData = getTokenData(this.config.clientId);
1170
1240
  if (!tokenData) return null;
1171
- if (Date.now() >= tokenData.expires_at) {
1241
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1172
1242
  tokenData = await this.refreshToken();
1173
1243
  }
1174
1244
  return tokenData.access_token;
@@ -1177,6 +1247,9 @@ var _SsoClient = class _SsoClient {
1177
1247
  * 检查是否已认证(不发起网络请求)
1178
1248
  *
1179
1249
  * 仅检查本地是否存在未过期的 access_token。
1250
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1251
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1252
+ * getAccessToken/getUserInfo 已带提前量。
1180
1253
  */
1181
1254
  isAuthenticated() {
1182
1255
  const tokenData = getTokenData(this.config.clientId);
@@ -1292,11 +1365,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1292
1365
  var LOCK_TTL_MS = 5e3;
1293
1366
  var CHANNEL_PREFIX = "nihplod_sso_events:";
1294
1367
  var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1295
- function broadcastSsoEvent(clientId, type) {
1368
+ function broadcastSsoEvent(clientId, type, tokenData) {
1296
1369
  if (typeof BroadcastChannel === "undefined") return;
1297
1370
  try {
1298
1371
  const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1299
- channel.postMessage({ type, sourceTabId: TAB_ID });
1372
+ channel.postMessage({
1373
+ type,
1374
+ sourceTabId: TAB_ID,
1375
+ ...type === "token" && tokenData ? { tokenData } : {}
1376
+ });
1300
1377
  channel.close();
1301
1378
  } catch {
1302
1379
  }
@@ -1432,7 +1509,7 @@ function SsoProvider({
1432
1509
  const td = await client.refreshToken();
1433
1510
  refreshFailureCountRef.current = 0;
1434
1511
  onTokenRefreshed?.(td.access_token);
1435
- broadcastSsoEvent(client.config.clientId, "token");
1512
+ broadcastSsoEvent(client.config.clientId, "token", td);
1436
1513
  loadUser();
1437
1514
  } catch (err) {
1438
1515
  if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
@@ -1515,6 +1592,10 @@ function SsoProvider({
1515
1592
  sessionExpiredNotifiedRef.current = false;
1516
1593
  refreshFailureCountRef.current = 0;
1517
1594
  } else if (data.type === "token") {
1595
+ const td = data.tokenData;
1596
+ if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
1597
+ saveTokenData(td, client.config.clientId);
1598
+ }
1518
1599
  loadUser();
1519
1600
  }
1520
1601
  };