@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.
@@ -58,6 +58,17 @@ interface SsoClientConfig {
58
58
  redirectUri: string;
59
59
  /** SSO 中心地址,如 "https://nihplod.cn" */
60
60
  ssoBaseUrl: string;
61
+ /**
62
+ * 允许 Discovery 文档声明跨源端点(默认 false)。
63
+ *
64
+ * ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
65
+ * token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
66
+ * SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
67
+ * token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
68
+ * 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
69
+ * 才设置此开关,并自行确保 Discovery 通道可信。
70
+ */
71
+ allowCrossOriginDiscoveryEndpoints?: boolean;
61
72
  /** 请求的 scope(空格分隔),如 "openid profile phone" */
62
73
  scopes?: string;
63
74
  /** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
@@ -128,6 +139,8 @@ declare class SsoClient {
128
139
  private _discovery;
129
140
  private _discoveryFetchedAt;
130
141
  private _refreshLock;
142
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
143
+ private _discoveryOriginWarned;
131
144
  /** Discovery 文档缓存 TTL(5 分钟) */
132
145
  private static readonly DISCOVERY_TTL_MS;
133
146
  /** Discovery fetch 超时(10 秒) */
@@ -139,6 +152,15 @@ declare class SsoClient {
139
152
  * 不会输出 token/授权码等敏感值。
140
153
  */
141
154
  debugLog(...args: unknown[]): void;
155
+ /**
156
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
157
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
158
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
159
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
160
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
161
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
162
+ */
163
+ private _sanitizeDiscovery;
142
164
  /**
143
165
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
144
166
  *
@@ -260,6 +282,9 @@ declare class SsoClient {
260
282
  * 检查是否已认证(不发起网络请求)
261
283
  *
262
284
  * 仅检查本地是否存在未过期的 access_token。
285
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
286
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
287
+ * getAccessToken/getUserInfo 已带提前量。
263
288
  */
264
289
  isAuthenticated(): boolean;
265
290
  /**
@@ -58,6 +58,17 @@ interface SsoClientConfig {
58
58
  redirectUri: string;
59
59
  /** SSO 中心地址,如 "https://nihplod.cn" */
60
60
  ssoBaseUrl: string;
61
+ /**
62
+ * 允许 Discovery 文档声明跨源端点(默认 false)。
63
+ *
64
+ * ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
65
+ * token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
66
+ * SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
67
+ * token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
68
+ * 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
69
+ * 才设置此开关,并自行确保 Discovery 通道可信。
70
+ */
71
+ allowCrossOriginDiscoveryEndpoints?: boolean;
61
72
  /** 请求的 scope(空格分隔),如 "openid profile phone" */
62
73
  scopes?: string;
63
74
  /** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
@@ -128,6 +139,8 @@ declare class SsoClient {
128
139
  private _discovery;
129
140
  private _discoveryFetchedAt;
130
141
  private _refreshLock;
142
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
143
+ private _discoveryOriginWarned;
131
144
  /** Discovery 文档缓存 TTL(5 分钟) */
132
145
  private static readonly DISCOVERY_TTL_MS;
133
146
  /** Discovery fetch 超时(10 秒) */
@@ -139,6 +152,15 @@ declare class SsoClient {
139
152
  * 不会输出 token/授权码等敏感值。
140
153
  */
141
154
  debugLog(...args: unknown[]): void;
155
+ /**
156
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
157
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
158
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
159
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
160
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
161
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
162
+ */
163
+ private _sanitizeDiscovery;
142
164
  /**
143
165
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
144
166
  *
@@ -260,6 +282,9 @@ declare class SsoClient {
260
282
  * 检查是否已认证(不发起网络请求)
261
283
  *
262
284
  * 仅检查本地是否存在未过期的 access_token。
285
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
286
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
287
+ * getAccessToken/getUserInfo 已带提前量。
263
288
  */
264
289
  isAuthenticated(): boolean;
265
290
  /**
@@ -539,7 +539,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
539
539
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
540
540
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
541
541
  }
542
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
542
+ if (typeof payload.iat !== "number") {
543
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
544
+ }
545
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
543
546
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
544
547
  }
545
548
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -556,13 +559,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
556
559
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
557
560
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
558
561
  }
562
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
563
+ throw new SsoError(
564
+ "id_token_nonce_mismatch",
565
+ "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"
566
+ );
559
567
  }
560
568
  return { sub: payload.sub };
561
569
  }
562
570
 
563
- // src/core/SsoClient.ts
571
+ // src/core/http.ts
564
572
  var REQUEST_TIMEOUT_MS = 1e4;
565
- var REVOKE_TIMEOUT_MS = 3e3;
566
573
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
567
574
  const controller = new AbortController();
568
575
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -572,6 +579,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
572
579
  clearTimeout(timer);
573
580
  }
574
581
  }
582
+
583
+ // src/core/SsoClient.ts
584
+ var REVOKE_TIMEOUT_MS = 3e3;
585
+ var EXPIRY_SKEW_MS = 1e4;
575
586
  function assertValidTokenResponse(data, requireRefreshToken) {
576
587
  if (typeof data.access_token !== "string" || !data.access_token) {
577
588
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -599,6 +610,8 @@ var _SsoClient = class _SsoClient {
599
610
  this._discovery = null;
600
611
  this._discoveryFetchedAt = 0;
601
612
  this._refreshLock = null;
613
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
614
+ this._discoveryOriginWarned = false;
602
615
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
603
616
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
604
617
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -622,6 +635,52 @@ var _SsoClient = class _SsoClient {
622
635
  // ============================================
623
636
  // 内部方法
624
637
  // ============================================
638
+ /**
639
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
640
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
641
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
642
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
643
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
644
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
645
+ */
646
+ _sanitizeDiscovery(d) {
647
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
648
+ const allowedOrigins = /* @__PURE__ */ new Set();
649
+ try {
650
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
651
+ allowedOrigins.add(new URL(this._serverBase).origin);
652
+ } catch {
653
+ return null;
654
+ }
655
+ const sameOrigin = (endpoint) => {
656
+ try {
657
+ return allowedOrigins.has(new URL(endpoint).origin);
658
+ } catch {
659
+ return false;
660
+ }
661
+ };
662
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
663
+ let authorizationOk = false;
664
+ try {
665
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
666
+ } catch {
667
+ authorizationOk = false;
668
+ }
669
+ const bad = [];
670
+ if (!authorizationOk) bad.push("authorization_endpoint");
671
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
672
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
673
+ if (bad.length > 0) {
674
+ if (!this._discoveryOriginWarned) {
675
+ this._discoveryOriginWarned = true;
676
+ console.warn(
677
+ `[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`
678
+ );
679
+ }
680
+ return null;
681
+ }
682
+ return d;
683
+ }
625
684
  /**
626
685
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
627
686
  *
@@ -648,7 +707,10 @@ var _SsoClient = class _SsoClient {
648
707
  );
649
708
  return null;
650
709
  }
651
- this._discovery = await res.json();
710
+ const doc = await res.json();
711
+ const sanitized = this._sanitizeDiscovery(doc);
712
+ if (!sanitized) return null;
713
+ this._discovery = sanitized;
652
714
  this._discoveryFetchedAt = now;
653
715
  return this._discovery;
654
716
  } catch (err) {
@@ -1008,6 +1070,10 @@ var _SsoClient = class _SsoClient {
1008
1070
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
1009
1071
  }
1010
1072
  if (!res.ok) {
1073
+ removeOAuthState(this.config.clientId);
1074
+ removeOAuthNonce(this.config.clientId);
1075
+ removePkceVerifier(this.config.clientId);
1076
+ removeSilentProbe(this.config.clientId);
1011
1077
  let errData = {};
1012
1078
  try {
1013
1079
  errData = await res.json();
@@ -1023,6 +1089,10 @@ var _SsoClient = class _SsoClient {
1023
1089
  assertValidTokenResponse(data, true);
1024
1090
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1025
1091
  if (requestedScopes.includes("openid") && !data.id_token) {
1092
+ removeOAuthState(this.config.clientId);
1093
+ removeOAuthNonce(this.config.clientId);
1094
+ removePkceVerifier(this.config.clientId);
1095
+ removeSilentProbe(this.config.clientId);
1026
1096
  removeTokenData(this.config.clientId);
1027
1097
  throw new SsoError(
1028
1098
  "id_token_invalid",
@@ -1167,7 +1237,7 @@ var _SsoClient = class _SsoClient {
1167
1237
  if (!tokenData) {
1168
1238
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1169
1239
  }
1170
- if (Date.now() >= tokenData.expires_at) {
1240
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1171
1241
  tokenData = await this.refreshToken();
1172
1242
  }
1173
1243
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1202,7 +1272,7 @@ var _SsoClient = class _SsoClient {
1202
1272
  async getAccessToken() {
1203
1273
  let tokenData = getTokenData(this.config.clientId);
1204
1274
  if (!tokenData) return null;
1205
- if (Date.now() >= tokenData.expires_at) {
1275
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1206
1276
  tokenData = await this.refreshToken();
1207
1277
  }
1208
1278
  return tokenData.access_token;
@@ -1211,6 +1281,9 @@ var _SsoClient = class _SsoClient {
1211
1281
  * 检查是否已认证(不发起网络请求)
1212
1282
  *
1213
1283
  * 仅检查本地是否存在未过期的 access_token。
1284
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1285
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1286
+ * getAccessToken/getUserInfo 已带提前量。
1214
1287
  */
1215
1288
  isAuthenticated() {
1216
1289
  const tokenData = getTokenData(this.config.clientId);
@@ -1326,11 +1399,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1326
1399
  var LOCK_TTL_MS = 5e3;
1327
1400
  var CHANNEL_PREFIX = "nihplod_sso_events:";
1328
1401
  var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1329
- function broadcastSsoEvent(clientId, type) {
1402
+ function broadcastSsoEvent(clientId, type, tokenData) {
1330
1403
  if (typeof BroadcastChannel === "undefined") return;
1331
1404
  try {
1332
1405
  const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1333
- channel.postMessage({ type, sourceTabId: TAB_ID });
1406
+ channel.postMessage({
1407
+ type,
1408
+ sourceTabId: TAB_ID,
1409
+ ...type === "token" && tokenData ? { tokenData } : {}
1410
+ });
1334
1411
  channel.close();
1335
1412
  } catch {
1336
1413
  }
@@ -1466,7 +1543,7 @@ function SsoProvider({
1466
1543
  const td = await client.refreshToken();
1467
1544
  refreshFailureCountRef.current = 0;
1468
1545
  onTokenRefreshed?.(td.access_token);
1469
- broadcastSsoEvent(client.config.clientId, "token");
1546
+ broadcastSsoEvent(client.config.clientId, "token", td);
1470
1547
  loadUser();
1471
1548
  } catch (err) {
1472
1549
  if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
@@ -1549,6 +1626,10 @@ function SsoProvider({
1549
1626
  sessionExpiredNotifiedRef.current = false;
1550
1627
  refreshFailureCountRef.current = 0;
1551
1628
  } else if (data.type === "token") {
1629
+ const td = data.tokenData;
1630
+ if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
1631
+ saveTokenData(td, client.config.clientId);
1632
+ }
1552
1633
  loadUser();
1553
1634
  }
1554
1635
  };