@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.
package/dist/index.mjs CHANGED
@@ -558,7 +558,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
558
558
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
559
559
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
560
560
  }
561
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
561
+ if (typeof payload.iat !== "number") {
562
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
563
+ }
564
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
562
565
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
563
566
  }
564
567
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -575,13 +578,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
575
578
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
576
579
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
577
580
  }
581
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
582
+ throw new SsoError(
583
+ "id_token_nonce_mismatch",
584
+ "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"
585
+ );
578
586
  }
579
587
  return { sub: payload.sub };
580
588
  }
581
589
 
582
- // src/core/SsoClient.ts
590
+ // src/core/http.ts
583
591
  var REQUEST_TIMEOUT_MS = 1e4;
584
- var REVOKE_TIMEOUT_MS = 3e3;
585
592
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
586
593
  const controller = new AbortController();
587
594
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -591,6 +598,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
591
598
  clearTimeout(timer);
592
599
  }
593
600
  }
601
+
602
+ // src/core/SsoClient.ts
603
+ var REVOKE_TIMEOUT_MS = 3e3;
604
+ var EXPIRY_SKEW_MS = 1e4;
594
605
  function assertValidTokenResponse(data, requireRefreshToken) {
595
606
  if (typeof data.access_token !== "string" || !data.access_token) {
596
607
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -618,6 +629,8 @@ var _SsoClient = class _SsoClient {
618
629
  this._discovery = null;
619
630
  this._discoveryFetchedAt = 0;
620
631
  this._refreshLock = null;
632
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
633
+ this._discoveryOriginWarned = false;
621
634
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
622
635
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
623
636
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -641,6 +654,52 @@ var _SsoClient = class _SsoClient {
641
654
  // ============================================
642
655
  // 内部方法
643
656
  // ============================================
657
+ /**
658
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
659
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
660
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
661
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
662
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
663
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
664
+ */
665
+ _sanitizeDiscovery(d) {
666
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
667
+ const allowedOrigins = /* @__PURE__ */ new Set();
668
+ try {
669
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
670
+ allowedOrigins.add(new URL(this._serverBase).origin);
671
+ } catch {
672
+ return null;
673
+ }
674
+ const sameOrigin = (endpoint) => {
675
+ try {
676
+ return allowedOrigins.has(new URL(endpoint).origin);
677
+ } catch {
678
+ return false;
679
+ }
680
+ };
681
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
682
+ let authorizationOk = false;
683
+ try {
684
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
685
+ } catch {
686
+ authorizationOk = false;
687
+ }
688
+ const bad = [];
689
+ if (!authorizationOk) bad.push("authorization_endpoint");
690
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
691
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
692
+ if (bad.length > 0) {
693
+ if (!this._discoveryOriginWarned) {
694
+ this._discoveryOriginWarned = true;
695
+ console.warn(
696
+ `[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`
697
+ );
698
+ }
699
+ return null;
700
+ }
701
+ return d;
702
+ }
644
703
  /**
645
704
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
646
705
  *
@@ -667,7 +726,10 @@ var _SsoClient = class _SsoClient {
667
726
  );
668
727
  return null;
669
728
  }
670
- this._discovery = await res.json();
729
+ const doc = await res.json();
730
+ const sanitized = this._sanitizeDiscovery(doc);
731
+ if (!sanitized) return null;
732
+ this._discovery = sanitized;
671
733
  this._discoveryFetchedAt = now;
672
734
  return this._discovery;
673
735
  } catch (err) {
@@ -1027,6 +1089,10 @@ var _SsoClient = class _SsoClient {
1027
1089
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
1028
1090
  }
1029
1091
  if (!res.ok) {
1092
+ removeOAuthState(this.config.clientId);
1093
+ removeOAuthNonce(this.config.clientId);
1094
+ removePkceVerifier(this.config.clientId);
1095
+ removeSilentProbe(this.config.clientId);
1030
1096
  let errData = {};
1031
1097
  try {
1032
1098
  errData = await res.json();
@@ -1042,6 +1108,10 @@ var _SsoClient = class _SsoClient {
1042
1108
  assertValidTokenResponse(data, true);
1043
1109
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1044
1110
  if (requestedScopes.includes("openid") && !data.id_token) {
1111
+ removeOAuthState(this.config.clientId);
1112
+ removeOAuthNonce(this.config.clientId);
1113
+ removePkceVerifier(this.config.clientId);
1114
+ removeSilentProbe(this.config.clientId);
1045
1115
  removeTokenData(this.config.clientId);
1046
1116
  throw new SsoError(
1047
1117
  "id_token_invalid",
@@ -1186,7 +1256,7 @@ var _SsoClient = class _SsoClient {
1186
1256
  if (!tokenData) {
1187
1257
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1188
1258
  }
1189
- if (Date.now() >= tokenData.expires_at) {
1259
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1190
1260
  tokenData = await this.refreshToken();
1191
1261
  }
1192
1262
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1221,7 +1291,7 @@ var _SsoClient = class _SsoClient {
1221
1291
  async getAccessToken() {
1222
1292
  let tokenData = getTokenData(this.config.clientId);
1223
1293
  if (!tokenData) return null;
1224
- if (Date.now() >= tokenData.expires_at) {
1294
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1225
1295
  tokenData = await this.refreshToken();
1226
1296
  }
1227
1297
  return tokenData.access_token;
@@ -1230,6 +1300,9 @@ var _SsoClient = class _SsoClient {
1230
1300
  * 检查是否已认证(不发起网络请求)
1231
1301
  *
1232
1302
  * 仅检查本地是否存在未过期的 access_token。
1303
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1304
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1305
+ * getAccessToken/getUserInfo 已带提前量。
1233
1306
  */
1234
1307
  isAuthenticated() {
1235
1308
  const tokenData = getTokenData(this.config.clientId);