@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.
@@ -90,24 +90,6 @@ var SILENT_PROBE_KEY = "silent_probe";
90
90
  function buildKey(base, clientId) {
91
91
  return clientId ? `${base}:${clientId}` : base;
92
92
  }
93
- function createMemoryStorageAdapter() {
94
- const store = /* @__PURE__ */ new Map();
95
- return {
96
- get(key) {
97
- return store.get(key) ?? null;
98
- },
99
- set(key, value) {
100
- store.set(key, value);
101
- },
102
- remove(key) {
103
- store.delete(key);
104
- },
105
- keys() {
106
- return [...store.keys()];
107
- }
108
- };
109
- }
110
- var memoryStorageAdapter = createMemoryStorageAdapter();
111
93
  function createSessionStorageAdapter() {
112
94
  const fallback = /* @__PURE__ */ new Map();
113
95
  return {
@@ -375,7 +357,19 @@ async function fetchJwks(baseUrl, options = {}) {
375
357
  return cachedJwks.jwks;
376
358
  }
377
359
  const discovery = await fetchDiscoveryDoc(baseUrl);
378
- const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
360
+ let jwksUri = `${baseUrl}/api/oauth/jwks`;
361
+ if (discovery?.jwks_uri) {
362
+ try {
363
+ if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
364
+ jwksUri = discovery.jwks_uri;
365
+ } else {
366
+ console.warn(
367
+ "[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
368
+ );
369
+ }
370
+ } catch {
371
+ }
372
+ }
379
373
  try {
380
374
  const res = await fetch(jwksUri, {
381
375
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -505,7 +499,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
505
499
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
506
500
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
507
501
  }
508
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
502
+ if (typeof payload.iat !== "number") {
503
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
504
+ }
505
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
509
506
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
510
507
  }
511
508
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -522,13 +519,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
522
519
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
523
520
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
524
521
  }
522
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
523
+ throw new SsoError(
524
+ "id_token_nonce_mismatch",
525
+ "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"
526
+ );
525
527
  }
526
528
  return { sub: payload.sub };
527
529
  }
528
530
 
529
- // src/core/SsoClient.ts
531
+ // src/core/http.ts
530
532
  var REQUEST_TIMEOUT_MS = 1e4;
531
- var REVOKE_TIMEOUT_MS = 3e3;
532
533
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
533
534
  const controller = new AbortController();
534
535
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -538,6 +539,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
538
539
  clearTimeout(timer);
539
540
  }
540
541
  }
542
+
543
+ // src/core/SsoClient.ts
544
+ var REVOKE_TIMEOUT_MS = 3e3;
545
+ var EXPIRY_SKEW_MS = 1e4;
541
546
  function assertValidTokenResponse(data, requireRefreshToken) {
542
547
  if (typeof data.access_token !== "string" || !data.access_token) {
543
548
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -555,16 +560,14 @@ function assertValidTokenResponse(data, requireRefreshToken) {
555
560
  );
556
561
  }
557
562
  }
558
- var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
559
- "login_required",
560
- "consent_required",
561
- "interaction_required"
562
- ]);
563
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set(["login_required", "consent_required", "interaction_required"]);
563
564
  var _SsoClient = class _SsoClient {
564
565
  constructor(config) {
565
566
  this._discovery = null;
566
567
  this._discoveryFetchedAt = 0;
567
568
  this._refreshLock = null;
569
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
570
+ this._discoveryOriginWarned = false;
568
571
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
569
572
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
570
573
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -588,6 +591,68 @@ var _SsoClient = class _SsoClient {
588
591
  // ============================================
589
592
  // 内部方法
590
593
  // ============================================
594
+ /**
595
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
596
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
597
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
598
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
599
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
600
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
601
+ */
602
+ _sanitizeDiscovery(d) {
603
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
604
+ const allowedOrigins = /* @__PURE__ */ new Set();
605
+ try {
606
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
607
+ allowedOrigins.add(new URL(this._serverBase).origin);
608
+ } catch {
609
+ return null;
610
+ }
611
+ const sameOrigin = (endpoint) => {
612
+ try {
613
+ return allowedOrigins.has(new URL(endpoint).origin);
614
+ } catch {
615
+ return false;
616
+ }
617
+ };
618
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
619
+ let authorizationOk = false;
620
+ try {
621
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
622
+ } catch {
623
+ authorizationOk = false;
624
+ }
625
+ const bad = [];
626
+ if (!authorizationOk) bad.push("authorization_endpoint");
627
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
628
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
629
+ if (d.jwks_uri && !sameOrigin(d.jwks_uri)) bad.push("jwks_uri");
630
+ if (d.revocation_endpoint && !sameOrigin(d.revocation_endpoint)) {
631
+ bad.push("revocation_endpoint");
632
+ }
633
+ if (d.introspection_endpoint && !sameOrigin(d.introspection_endpoint)) {
634
+ bad.push("introspection_endpoint");
635
+ }
636
+ if (d.end_session_endpoint) {
637
+ try {
638
+ if (new URL(d.end_session_endpoint).origin !== publicOrigin) {
639
+ bad.push("end_session_endpoint");
640
+ }
641
+ } catch {
642
+ bad.push("end_session_endpoint");
643
+ }
644
+ }
645
+ if (bad.length > 0) {
646
+ if (!this._discoveryOriginWarned) {
647
+ this._discoveryOriginWarned = true;
648
+ console.warn(
649
+ `[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`
650
+ );
651
+ }
652
+ return null;
653
+ }
654
+ return d;
655
+ }
591
656
  /**
592
657
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
593
658
  *
@@ -602,19 +667,17 @@ var _SsoClient = class _SsoClient {
602
667
  const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
603
668
  try {
604
669
  const controller = new AbortController();
605
- const timeoutId = setTimeout(
606
- () => controller.abort(),
607
- _SsoClient.DISCOVERY_TIMEOUT_MS
608
- );
670
+ const timeoutId = setTimeout(() => controller.abort(), _SsoClient.DISCOVERY_TIMEOUT_MS);
609
671
  const res = await fetch(url, { signal: controller.signal });
610
672
  clearTimeout(timeoutId);
611
673
  if (!res.ok) {
612
- console.warn(
613
- `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
614
- );
674
+ console.warn(`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`);
615
675
  return null;
616
676
  }
617
- this._discovery = await res.json();
677
+ const doc = await res.json();
678
+ const sanitized = this._sanitizeDiscovery(doc);
679
+ if (!sanitized) return null;
680
+ this._discovery = sanitized;
618
681
  this._discoveryFetchedAt = now;
619
682
  return this._discovery;
620
683
  } catch (err) {
@@ -823,10 +886,7 @@ var _SsoClient = class _SsoClient {
823
886
  removeOAuthState(this.config.clientId);
824
887
  removeOAuthNonce(this.config.clientId);
825
888
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
826
- throw new SsoError(
827
- "popup_blocked",
828
- "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
829
- );
889
+ throw new SsoError("popup_blocked", "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5");
830
890
  }
831
891
  const redirectUriOrigin = new URL(this.config.redirectUri).origin;
832
892
  try {
@@ -855,9 +915,7 @@ var _SsoClient = class _SsoClient {
855
915
  }
856
916
  this.handleCallback(event.data.callbackUrl).then((tokenData) => {
857
917
  if (tokenData === null) {
858
- reject(
859
- new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
860
- );
918
+ reject(new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09"));
861
919
  return;
862
920
  }
863
921
  resolve(tokenData);
@@ -868,12 +926,7 @@ var _SsoClient = class _SsoClient {
868
926
  if (!completed) {
869
927
  completed = true;
870
928
  cleanup();
871
- reject(
872
- new SsoError(
873
- "popup_closed",
874
- "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
875
- )
876
- );
929
+ reject(new SsoError("popup_closed", "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"));
877
930
  }
878
931
  }
879
932
  }, 500);
@@ -940,18 +993,24 @@ var _SsoClient = class _SsoClient {
940
993
  removeOAuthNonce(this.config.clientId);
941
994
  removePkceVerifier(this.config.clientId);
942
995
  removeSilentProbe(this.config.clientId);
943
- throw new SsoError(
944
- "state_mismatch",
945
- "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
946
- );
996
+ throw new SsoError("state_mismatch", "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB");
997
+ }
998
+ const returnedIss = params.get("iss");
999
+ if (returnedIss) {
1000
+ const discovery = await this._getDiscovery();
1001
+ const expectedIssuer = (discovery?.issuer || this.config.ssoBaseUrl).replace(/\/+$/, "");
1002
+ if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
1003
+ removeOAuthState(this.config.clientId);
1004
+ removeOAuthNonce(this.config.clientId);
1005
+ removePkceVerifier(this.config.clientId);
1006
+ removeSilentProbe(this.config.clientId);
1007
+ throw new SsoError("issuer_mismatch", "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 IdP \u6DF7\u53E0\u653B\u51FB");
1008
+ }
947
1009
  }
948
1010
  const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
949
1011
  const verifier = getPkceVerifier(this.config.clientId);
950
1012
  if (!verifier) {
951
- throw new SsoError(
952
- "pkce_required",
953
- "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
954
- );
1013
+ 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");
955
1014
  }
956
1015
  const tokenEndpoint = await this._getTokenEndpoint();
957
1016
  const body = new URLSearchParams();
@@ -974,6 +1033,10 @@ var _SsoClient = class _SsoClient {
974
1033
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
975
1034
  }
976
1035
  if (!res.ok) {
1036
+ removeOAuthState(this.config.clientId);
1037
+ removeOAuthNonce(this.config.clientId);
1038
+ removePkceVerifier(this.config.clientId);
1039
+ removeSilentProbe(this.config.clientId);
977
1040
  let errData = {};
978
1041
  try {
979
1042
  errData = await res.json();
@@ -989,11 +1052,12 @@ var _SsoClient = class _SsoClient {
989
1052
  assertValidTokenResponse(data, true);
990
1053
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
991
1054
  if (requestedScopes.includes("openid") && !data.id_token) {
1055
+ removeOAuthState(this.config.clientId);
1056
+ removeOAuthNonce(this.config.clientId);
1057
+ removePkceVerifier(this.config.clientId);
1058
+ removeSilentProbe(this.config.clientId);
992
1059
  removeTokenData(this.config.clientId);
993
- throw new SsoError(
994
- "id_token_invalid",
995
- "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
996
- );
1060
+ throw new SsoError("id_token_invalid", "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09");
997
1061
  }
998
1062
  if (data.id_token) {
999
1063
  try {
@@ -1058,19 +1122,14 @@ var _SsoClient = class _SsoClient {
1058
1122
  }
1059
1123
  let res = null;
1060
1124
  let lastErr;
1061
- for (let attempt = 0; attempt < 2; attempt++) {
1062
- try {
1063
- res = await fetchWithTimeout(tokenEndpoint, {
1064
- method: "POST",
1065
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1066
- body: body.toString()
1067
- });
1068
- lastErr = null;
1069
- break;
1070
- } catch (err) {
1071
- lastErr = err;
1072
- if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
1073
- }
1125
+ try {
1126
+ res = await fetchWithTimeout(tokenEndpoint, {
1127
+ method: "POST",
1128
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1129
+ body: body.toString()
1130
+ });
1131
+ } catch (err) {
1132
+ lastErr = err;
1074
1133
  }
1075
1134
  if (lastErr || !res) {
1076
1135
  throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
@@ -1133,7 +1192,7 @@ var _SsoClient = class _SsoClient {
1133
1192
  if (!tokenData) {
1134
1193
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1135
1194
  }
1136
- if (Date.now() >= tokenData.expires_at) {
1195
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1137
1196
  tokenData = await this.refreshToken();
1138
1197
  }
1139
1198
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1152,10 +1211,7 @@ var _SsoClient = class _SsoClient {
1152
1211
  removeTokenData(this.config.clientId);
1153
1212
  throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
1154
1213
  }
1155
- throw new SsoError(
1156
- "userinfo_failed",
1157
- `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
1158
- );
1214
+ throw new SsoError("userinfo_failed", `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`);
1159
1215
  }
1160
1216
  return await res.json();
1161
1217
  }
@@ -1168,7 +1224,7 @@ var _SsoClient = class _SsoClient {
1168
1224
  async getAccessToken() {
1169
1225
  let tokenData = getTokenData(this.config.clientId);
1170
1226
  if (!tokenData) return null;
1171
- if (Date.now() >= tokenData.expires_at) {
1227
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1172
1228
  tokenData = await this.refreshToken();
1173
1229
  }
1174
1230
  return tokenData.access_token;
@@ -1177,6 +1233,9 @@ var _SsoClient = class _SsoClient {
1177
1233
  * 检查是否已认证(不发起网络请求)
1178
1234
  *
1179
1235
  * 仅检查本地是否存在未过期的 access_token。
1236
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1237
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1238
+ * getAccessToken/getUserInfo 已带提前量。
1180
1239
  */
1181
1240
  isAuthenticated() {
1182
1241
  const tokenData = getTokenData(this.config.clientId);
@@ -1212,7 +1271,7 @@ var _SsoClient = class _SsoClient {
1212
1271
  if (this.config.clientSecret) {
1213
1272
  revokeBody.set("client_secret", this.config.clientSecret);
1214
1273
  }
1215
- await fetchWithTimeout(
1274
+ const doRevoke = () => fetchWithTimeout(
1216
1275
  revokeUrl,
1217
1276
  {
1218
1277
  method: "POST",
@@ -1221,6 +1280,14 @@ var _SsoClient = class _SsoClient {
1221
1280
  },
1222
1281
  REVOKE_TIMEOUT_MS
1223
1282
  );
1283
+ let res = await doRevoke();
1284
+ if (!res.ok && (res.status >= 500 || res.status === 429)) {
1285
+ await new Promise((resolve) => setTimeout(resolve, 300));
1286
+ res = await doRevoke();
1287
+ }
1288
+ if (!res.ok) {
1289
+ this.debugLog("logout: \u670D\u52A1\u7AEF\u64A4\u9500 refresh_token \u5931\u8D25", { status: res.status });
1290
+ }
1224
1291
  } catch {
1225
1292
  }
1226
1293
  }
@@ -1231,10 +1298,7 @@ var _SsoClient = class _SsoClient {
1231
1298
  logoutUrl.searchParams.set("client_id", this.config.clientId);
1232
1299
  const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1233
1300
  if (postLogoutUri) {
1234
- logoutUrl.searchParams.set(
1235
- "post_logout_redirect_uri",
1236
- postLogoutUri
1237
- );
1301
+ logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutUri);
1238
1302
  }
1239
1303
  if (idTokenHint) {
1240
1304
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
@@ -1292,11 +1356,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1292
1356
  var LOCK_TTL_MS = 5e3;
1293
1357
  var CHANNEL_PREFIX = "nihplod_sso_events:";
1294
1358
  var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1295
- function broadcastSsoEvent(clientId, type) {
1359
+ function broadcastSsoEvent(clientId, type, tokenData) {
1296
1360
  if (typeof BroadcastChannel === "undefined") return;
1297
1361
  try {
1298
1362
  const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1299
- channel.postMessage({ type, sourceTabId: TAB_ID });
1363
+ channel.postMessage({
1364
+ type,
1365
+ sourceTabId: TAB_ID,
1366
+ ...type === "token" && tokenData ? { tokenData } : {}
1367
+ });
1300
1368
  channel.close();
1301
1369
  } catch {
1302
1370
  }
@@ -1432,7 +1500,7 @@ function SsoProvider({
1432
1500
  const td = await client.refreshToken();
1433
1501
  refreshFailureCountRef.current = 0;
1434
1502
  onTokenRefreshed?.(td.access_token);
1435
- broadcastSsoEvent(client.config.clientId, "token");
1503
+ broadcastSsoEvent(client.config.clientId, "token", td);
1436
1504
  loadUser();
1437
1505
  } catch (err) {
1438
1506
  if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
@@ -1460,7 +1528,15 @@ function SsoProvider({
1460
1528
  const tokenData = getTokenData(client.config.clientId);
1461
1529
  if (!tokenData) return;
1462
1530
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
1463
- if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1531
+ const lifetimeSec = Math.max(
1532
+ 1,
1533
+ (tokenData.expires_at - tokenData.issued_at) / 1e3
1534
+ );
1535
+ const effectiveThreshold = Math.max(
1536
+ 1,
1537
+ Math.min(refreshThreshold, Math.floor(lifetimeSec / 3))
1538
+ );
1539
+ if (remainingSec <= 0 || remainingSec <= effectiveThreshold) {
1464
1540
  refreshTimerRef.current = setTimeout(
1465
1541
  () => {
1466
1542
  if (active) void attemptRefresh();
@@ -1469,13 +1545,13 @@ function SsoProvider({
1469
1545
  );
1470
1546
  return;
1471
1547
  }
1472
- const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1548
+ const delayMs = Math.max((remainingSec - effectiveThreshold) * 1e3, minDelayMs, 1e3);
1473
1549
  refreshTimerRef.current = setTimeout(() => {
1474
1550
  if (!active) return;
1475
1551
  const td = getTokenData(client.config.clientId);
1476
1552
  if (!td) return;
1477
1553
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1478
- if (secLeft <= refreshThreshold) {
1554
+ if (secLeft <= effectiveThreshold) {
1479
1555
  void attemptRefresh();
1480
1556
  } else {
1481
1557
  scheduleNextRefresh();
@@ -1490,7 +1566,7 @@ function SsoProvider({
1490
1566
  refreshTimerRef.current = null;
1491
1567
  }
1492
1568
  };
1493
- }, [client, loadUser, refreshThreshold, onTokenRefreshed]);
1569
+ }, [client, loadUser, refreshThreshold, onTokenRefreshed, markSessionExpired]);
1494
1570
  useEffect(() => {
1495
1571
  const handleStorageChange = (e) => {
1496
1572
  if (e.key && e.key.startsWith("nihplod_sso_") && !e.key.startsWith(REFRESH_LOCK_PREFIX)) {
@@ -1515,6 +1591,10 @@ function SsoProvider({
1515
1591
  sessionExpiredNotifiedRef.current = false;
1516
1592
  refreshFailureCountRef.current = 0;
1517
1593
  } else if (data.type === "token") {
1594
+ const td = data.tokenData;
1595
+ if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
1596
+ saveTokenData(td, client.config.clientId);
1597
+ }
1518
1598
  loadUser();
1519
1599
  }
1520
1600
  };
@@ -1581,6 +1661,26 @@ function useSso() {
1581
1661
 
1582
1662
  // src/react/RequireAuth.tsx
1583
1663
  import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
1664
+ function LoginErrorContent({
1665
+ loginError,
1666
+ retry,
1667
+ renderLoginError
1668
+ }) {
1669
+ if (renderLoginError) {
1670
+ return React2.createElement(
1671
+ React2.Fragment,
1672
+ null,
1673
+ renderLoginError(loginError, retry)
1674
+ );
1675
+ }
1676
+ const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1677
+ return React2.createElement(
1678
+ "div",
1679
+ null,
1680
+ React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1681
+ React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1682
+ );
1683
+ }
1584
1684
  function RequireAuth({
1585
1685
  children,
1586
1686
  fallback,
@@ -1626,20 +1726,11 @@ function RequireAuth({
1626
1726
  }
1627
1727
  if (!isAuthenticated) {
1628
1728
  if (loginError) {
1629
- if (renderLoginError) {
1630
- return React2.createElement(
1631
- React2.Fragment,
1632
- null,
1633
- renderLoginError(loginError, retry)
1634
- );
1635
- }
1636
- const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1637
- return React2.createElement(
1638
- "div",
1639
- null,
1640
- React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1641
- React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1642
- );
1729
+ return React2.createElement(LoginErrorContent, {
1730
+ loginError,
1731
+ retry,
1732
+ renderLoginError
1733
+ });
1643
1734
  }
1644
1735
  if (fallback) return React2.createElement(React2.Fragment, null, fallback);
1645
1736
  return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");