@nihplod/sso-sdk 1.5.0 → 1.7.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
@@ -89,24 +89,6 @@ var SILENT_PROBE_KEY = "silent_probe";
89
89
  function buildKey(base, clientId) {
90
90
  return clientId ? `${base}:${clientId}` : base;
91
91
  }
92
- function createMemoryStorageAdapter() {
93
- const store = /* @__PURE__ */ new Map();
94
- return {
95
- get(key) {
96
- return store.get(key) ?? null;
97
- },
98
- set(key, value) {
99
- store.set(key, value);
100
- },
101
- remove(key) {
102
- store.delete(key);
103
- },
104
- keys() {
105
- return [...store.keys()];
106
- }
107
- };
108
- }
109
- var memoryStorageAdapter = createMemoryStorageAdapter();
110
92
  var localStorageAdapter = {
111
93
  get(key) {
112
94
  try {
@@ -428,7 +410,19 @@ async function fetchJwks(baseUrl, options = {}) {
428
410
  return cachedJwks.jwks;
429
411
  }
430
412
  const discovery = await fetchDiscoveryDoc(baseUrl);
431
- const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
413
+ let jwksUri = `${baseUrl}/api/oauth/jwks`;
414
+ if (discovery?.jwks_uri) {
415
+ try {
416
+ if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
417
+ jwksUri = discovery.jwks_uri;
418
+ } else {
419
+ console.warn(
420
+ "[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
421
+ );
422
+ }
423
+ } catch {
424
+ }
425
+ }
432
426
  try {
433
427
  const res = await fetch(jwksUri, {
434
428
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -558,7 +552,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
558
552
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
559
553
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
560
554
  }
561
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
555
+ if (typeof payload.iat !== "number") {
556
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
557
+ }
558
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
562
559
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
563
560
  }
564
561
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -575,13 +572,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
575
572
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
576
573
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
577
574
  }
575
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
576
+ throw new SsoError(
577
+ "id_token_nonce_mismatch",
578
+ "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"
579
+ );
578
580
  }
579
581
  return { sub: payload.sub };
580
582
  }
581
583
 
582
- // src/core/SsoClient.ts
584
+ // src/core/http.ts
583
585
  var REQUEST_TIMEOUT_MS = 1e4;
584
- var REVOKE_TIMEOUT_MS = 3e3;
585
586
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
586
587
  const controller = new AbortController();
587
588
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -591,6 +592,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
591
592
  clearTimeout(timer);
592
593
  }
593
594
  }
595
+
596
+ // src/core/SsoClient.ts
597
+ var REVOKE_TIMEOUT_MS = 3e3;
598
+ var EXPIRY_SKEW_MS = 1e4;
594
599
  function assertValidTokenResponse(data, requireRefreshToken) {
595
600
  if (typeof data.access_token !== "string" || !data.access_token) {
596
601
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -608,16 +613,14 @@ function assertValidTokenResponse(data, requireRefreshToken) {
608
613
  );
609
614
  }
610
615
  }
611
- var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
612
- "login_required",
613
- "consent_required",
614
- "interaction_required"
615
- ]);
616
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set(["login_required", "consent_required", "interaction_required"]);
616
617
  var _SsoClient = class _SsoClient {
617
618
  constructor(config) {
618
619
  this._discovery = null;
619
620
  this._discoveryFetchedAt = 0;
620
621
  this._refreshLock = null;
622
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
623
+ this._discoveryOriginWarned = false;
621
624
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
622
625
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
623
626
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -641,6 +644,68 @@ var _SsoClient = class _SsoClient {
641
644
  // ============================================
642
645
  // 内部方法
643
646
  // ============================================
647
+ /**
648
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
649
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
650
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
651
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
652
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
653
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
654
+ */
655
+ _sanitizeDiscovery(d) {
656
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
657
+ const allowedOrigins = /* @__PURE__ */ new Set();
658
+ try {
659
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
660
+ allowedOrigins.add(new URL(this._serverBase).origin);
661
+ } catch {
662
+ return null;
663
+ }
664
+ const sameOrigin = (endpoint) => {
665
+ try {
666
+ return allowedOrigins.has(new URL(endpoint).origin);
667
+ } catch {
668
+ return false;
669
+ }
670
+ };
671
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
672
+ let authorizationOk = false;
673
+ try {
674
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
675
+ } catch {
676
+ authorizationOk = false;
677
+ }
678
+ const bad = [];
679
+ if (!authorizationOk) bad.push("authorization_endpoint");
680
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
681
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
682
+ if (d.jwks_uri && !sameOrigin(d.jwks_uri)) bad.push("jwks_uri");
683
+ if (d.revocation_endpoint && !sameOrigin(d.revocation_endpoint)) {
684
+ bad.push("revocation_endpoint");
685
+ }
686
+ if (d.introspection_endpoint && !sameOrigin(d.introspection_endpoint)) {
687
+ bad.push("introspection_endpoint");
688
+ }
689
+ if (d.end_session_endpoint) {
690
+ try {
691
+ if (new URL(d.end_session_endpoint).origin !== publicOrigin) {
692
+ bad.push("end_session_endpoint");
693
+ }
694
+ } catch {
695
+ bad.push("end_session_endpoint");
696
+ }
697
+ }
698
+ if (bad.length > 0) {
699
+ if (!this._discoveryOriginWarned) {
700
+ this._discoveryOriginWarned = true;
701
+ console.warn(
702
+ `[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`
703
+ );
704
+ }
705
+ return null;
706
+ }
707
+ return d;
708
+ }
644
709
  /**
645
710
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
646
711
  *
@@ -655,19 +720,17 @@ var _SsoClient = class _SsoClient {
655
720
  const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
656
721
  try {
657
722
  const controller = new AbortController();
658
- const timeoutId = setTimeout(
659
- () => controller.abort(),
660
- _SsoClient.DISCOVERY_TIMEOUT_MS
661
- );
723
+ const timeoutId = setTimeout(() => controller.abort(), _SsoClient.DISCOVERY_TIMEOUT_MS);
662
724
  const res = await fetch(url, { signal: controller.signal });
663
725
  clearTimeout(timeoutId);
664
726
  if (!res.ok) {
665
- console.warn(
666
- `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
667
- );
727
+ console.warn(`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`);
668
728
  return null;
669
729
  }
670
- this._discovery = await res.json();
730
+ const doc = await res.json();
731
+ const sanitized = this._sanitizeDiscovery(doc);
732
+ if (!sanitized) return null;
733
+ this._discovery = sanitized;
671
734
  this._discoveryFetchedAt = now;
672
735
  return this._discovery;
673
736
  } catch (err) {
@@ -876,10 +939,7 @@ var _SsoClient = class _SsoClient {
876
939
  removeOAuthState(this.config.clientId);
877
940
  removeOAuthNonce(this.config.clientId);
878
941
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
879
- throw new SsoError(
880
- "popup_blocked",
881
- "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
882
- );
942
+ throw new SsoError("popup_blocked", "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5");
883
943
  }
884
944
  const redirectUriOrigin = new URL(this.config.redirectUri).origin;
885
945
  try {
@@ -908,9 +968,7 @@ var _SsoClient = class _SsoClient {
908
968
  }
909
969
  this.handleCallback(event.data.callbackUrl).then((tokenData) => {
910
970
  if (tokenData === null) {
911
- reject(
912
- new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
913
- );
971
+ reject(new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09"));
914
972
  return;
915
973
  }
916
974
  resolve(tokenData);
@@ -921,12 +979,7 @@ var _SsoClient = class _SsoClient {
921
979
  if (!completed) {
922
980
  completed = true;
923
981
  cleanup();
924
- reject(
925
- new SsoError(
926
- "popup_closed",
927
- "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
928
- )
929
- );
982
+ reject(new SsoError("popup_closed", "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"));
930
983
  }
931
984
  }
932
985
  }, 500);
@@ -993,18 +1046,24 @@ var _SsoClient = class _SsoClient {
993
1046
  removeOAuthNonce(this.config.clientId);
994
1047
  removePkceVerifier(this.config.clientId);
995
1048
  removeSilentProbe(this.config.clientId);
996
- throw new SsoError(
997
- "state_mismatch",
998
- "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
999
- );
1049
+ throw new SsoError("state_mismatch", "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB");
1050
+ }
1051
+ const returnedIss = params.get("iss");
1052
+ if (returnedIss) {
1053
+ const discovery = await this._getDiscovery();
1054
+ const expectedIssuer = (discovery?.issuer || this.config.ssoBaseUrl).replace(/\/+$/, "");
1055
+ if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
1056
+ removeOAuthState(this.config.clientId);
1057
+ removeOAuthNonce(this.config.clientId);
1058
+ removePkceVerifier(this.config.clientId);
1059
+ removeSilentProbe(this.config.clientId);
1060
+ throw new SsoError("issuer_mismatch", "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 IdP \u6DF7\u53E0\u653B\u51FB");
1061
+ }
1000
1062
  }
1001
1063
  const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
1002
1064
  const verifier = getPkceVerifier(this.config.clientId);
1003
1065
  if (!verifier) {
1004
- throw new SsoError(
1005
- "pkce_required",
1006
- "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
1007
- );
1066
+ throw new SsoError("pkce_required", "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09");
1008
1067
  }
1009
1068
  const tokenEndpoint = await this._getTokenEndpoint();
1010
1069
  const body = new URLSearchParams();
@@ -1027,6 +1086,10 @@ var _SsoClient = class _SsoClient {
1027
1086
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
1028
1087
  }
1029
1088
  if (!res.ok) {
1089
+ removeOAuthState(this.config.clientId);
1090
+ removeOAuthNonce(this.config.clientId);
1091
+ removePkceVerifier(this.config.clientId);
1092
+ removeSilentProbe(this.config.clientId);
1030
1093
  let errData = {};
1031
1094
  try {
1032
1095
  errData = await res.json();
@@ -1042,11 +1105,12 @@ var _SsoClient = class _SsoClient {
1042
1105
  assertValidTokenResponse(data, true);
1043
1106
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1044
1107
  if (requestedScopes.includes("openid") && !data.id_token) {
1108
+ removeOAuthState(this.config.clientId);
1109
+ removeOAuthNonce(this.config.clientId);
1110
+ removePkceVerifier(this.config.clientId);
1111
+ removeSilentProbe(this.config.clientId);
1045
1112
  removeTokenData(this.config.clientId);
1046
- throw new SsoError(
1047
- "id_token_invalid",
1048
- "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1049
- );
1113
+ throw new SsoError("id_token_invalid", "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09");
1050
1114
  }
1051
1115
  if (data.id_token) {
1052
1116
  try {
@@ -1111,19 +1175,14 @@ var _SsoClient = class _SsoClient {
1111
1175
  }
1112
1176
  let res = null;
1113
1177
  let lastErr;
1114
- for (let attempt = 0; attempt < 2; attempt++) {
1115
- try {
1116
- res = await fetchWithTimeout(tokenEndpoint, {
1117
- method: "POST",
1118
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1119
- body: body.toString()
1120
- });
1121
- lastErr = null;
1122
- break;
1123
- } catch (err) {
1124
- lastErr = err;
1125
- if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
1126
- }
1178
+ try {
1179
+ res = await fetchWithTimeout(tokenEndpoint, {
1180
+ method: "POST",
1181
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1182
+ body: body.toString()
1183
+ });
1184
+ } catch (err) {
1185
+ lastErr = err;
1127
1186
  }
1128
1187
  if (lastErr || !res) {
1129
1188
  throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
@@ -1186,7 +1245,7 @@ var _SsoClient = class _SsoClient {
1186
1245
  if (!tokenData) {
1187
1246
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1188
1247
  }
1189
- if (Date.now() >= tokenData.expires_at) {
1248
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1190
1249
  tokenData = await this.refreshToken();
1191
1250
  }
1192
1251
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1205,10 +1264,7 @@ var _SsoClient = class _SsoClient {
1205
1264
  removeTokenData(this.config.clientId);
1206
1265
  throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
1207
1266
  }
1208
- throw new SsoError(
1209
- "userinfo_failed",
1210
- `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
1211
- );
1267
+ throw new SsoError("userinfo_failed", `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`);
1212
1268
  }
1213
1269
  return await res.json();
1214
1270
  }
@@ -1221,7 +1277,7 @@ var _SsoClient = class _SsoClient {
1221
1277
  async getAccessToken() {
1222
1278
  let tokenData = getTokenData(this.config.clientId);
1223
1279
  if (!tokenData) return null;
1224
- if (Date.now() >= tokenData.expires_at) {
1280
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1225
1281
  tokenData = await this.refreshToken();
1226
1282
  }
1227
1283
  return tokenData.access_token;
@@ -1230,6 +1286,9 @@ var _SsoClient = class _SsoClient {
1230
1286
  * 检查是否已认证(不发起网络请求)
1231
1287
  *
1232
1288
  * 仅检查本地是否存在未过期的 access_token。
1289
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1290
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1291
+ * getAccessToken/getUserInfo 已带提前量。
1233
1292
  */
1234
1293
  isAuthenticated() {
1235
1294
  const tokenData = getTokenData(this.config.clientId);
@@ -1265,7 +1324,7 @@ var _SsoClient = class _SsoClient {
1265
1324
  if (this.config.clientSecret) {
1266
1325
  revokeBody.set("client_secret", this.config.clientSecret);
1267
1326
  }
1268
- await fetchWithTimeout(
1327
+ const doRevoke = () => fetchWithTimeout(
1269
1328
  revokeUrl,
1270
1329
  {
1271
1330
  method: "POST",
@@ -1274,6 +1333,14 @@ var _SsoClient = class _SsoClient {
1274
1333
  },
1275
1334
  REVOKE_TIMEOUT_MS
1276
1335
  );
1336
+ let res = await doRevoke();
1337
+ if (!res.ok && (res.status >= 500 || res.status === 429)) {
1338
+ await new Promise((resolve) => setTimeout(resolve, 300));
1339
+ res = await doRevoke();
1340
+ }
1341
+ if (!res.ok) {
1342
+ this.debugLog("logout: \u670D\u52A1\u7AEF\u64A4\u9500 refresh_token \u5931\u8D25", { status: res.status });
1343
+ }
1277
1344
  } catch {
1278
1345
  }
1279
1346
  }
@@ -1284,10 +1351,7 @@ var _SsoClient = class _SsoClient {
1284
1351
  logoutUrl.searchParams.set("client_id", this.config.clientId);
1285
1352
  const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1286
1353
  if (postLogoutUri) {
1287
- logoutUrl.searchParams.set(
1288
- "post_logout_redirect_uri",
1289
- postLogoutUri
1290
- );
1354
+ logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutUri);
1291
1355
  }
1292
1356
  if (idTokenHint) {
1293
1357
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);