@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.
@@ -124,24 +124,6 @@ var SILENT_PROBE_KEY = "silent_probe";
124
124
  function buildKey(base, clientId) {
125
125
  return clientId ? `${base}:${clientId}` : base;
126
126
  }
127
- function createMemoryStorageAdapter() {
128
- const store = /* @__PURE__ */ new Map();
129
- return {
130
- get(key) {
131
- return store.get(key) ?? null;
132
- },
133
- set(key, value) {
134
- store.set(key, value);
135
- },
136
- remove(key) {
137
- store.delete(key);
138
- },
139
- keys() {
140
- return [...store.keys()];
141
- }
142
- };
143
- }
144
- var memoryStorageAdapter = createMemoryStorageAdapter();
145
127
  function createSessionStorageAdapter() {
146
128
  const fallback = /* @__PURE__ */ new Map();
147
129
  return {
@@ -409,7 +391,19 @@ async function fetchJwks(baseUrl, options = {}) {
409
391
  return cachedJwks.jwks;
410
392
  }
411
393
  const discovery = await fetchDiscoveryDoc(baseUrl);
412
- const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
394
+ let jwksUri = `${baseUrl}/api/oauth/jwks`;
395
+ if (discovery?.jwks_uri) {
396
+ try {
397
+ if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
398
+ jwksUri = discovery.jwks_uri;
399
+ } else {
400
+ console.warn(
401
+ "[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
402
+ );
403
+ }
404
+ } catch {
405
+ }
406
+ }
413
407
  try {
414
408
  const res = await fetch(jwksUri, {
415
409
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -539,7 +533,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
539
533
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
540
534
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
541
535
  }
542
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
536
+ if (typeof payload.iat !== "number") {
537
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
538
+ }
539
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
543
540
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
544
541
  }
545
542
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -556,13 +553,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
556
553
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
557
554
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
558
555
  }
556
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
557
+ throw new SsoError(
558
+ "id_token_nonce_mismatch",
559
+ "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"
560
+ );
559
561
  }
560
562
  return { sub: payload.sub };
561
563
  }
562
564
 
563
- // src/core/SsoClient.ts
565
+ // src/core/http.ts
564
566
  var REQUEST_TIMEOUT_MS = 1e4;
565
- var REVOKE_TIMEOUT_MS = 3e3;
566
567
  async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
567
568
  const controller = new AbortController();
568
569
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -572,6 +573,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
572
573
  clearTimeout(timer);
573
574
  }
574
575
  }
576
+
577
+ // src/core/SsoClient.ts
578
+ var REVOKE_TIMEOUT_MS = 3e3;
579
+ var EXPIRY_SKEW_MS = 1e4;
575
580
  function assertValidTokenResponse(data, requireRefreshToken) {
576
581
  if (typeof data.access_token !== "string" || !data.access_token) {
577
582
  throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
@@ -589,16 +594,14 @@ function assertValidTokenResponse(data, requireRefreshToken) {
589
594
  );
590
595
  }
591
596
  }
592
- var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
593
- "login_required",
594
- "consent_required",
595
- "interaction_required"
596
- ]);
597
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set(["login_required", "consent_required", "interaction_required"]);
597
598
  var _SsoClient = class _SsoClient {
598
599
  constructor(config) {
599
600
  this._discovery = null;
600
601
  this._discoveryFetchedAt = 0;
601
602
  this._refreshLock = null;
603
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
604
+ this._discoveryOriginWarned = false;
602
605
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
603
606
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
604
607
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -622,6 +625,68 @@ var _SsoClient = class _SsoClient {
622
625
  // ============================================
623
626
  // 内部方法
624
627
  // ============================================
628
+ /**
629
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
630
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
631
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
632
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
633
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
634
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
635
+ */
636
+ _sanitizeDiscovery(d) {
637
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
638
+ const allowedOrigins = /* @__PURE__ */ new Set();
639
+ try {
640
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
641
+ allowedOrigins.add(new URL(this._serverBase).origin);
642
+ } catch {
643
+ return null;
644
+ }
645
+ const sameOrigin = (endpoint) => {
646
+ try {
647
+ return allowedOrigins.has(new URL(endpoint).origin);
648
+ } catch {
649
+ return false;
650
+ }
651
+ };
652
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
653
+ let authorizationOk = false;
654
+ try {
655
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
656
+ } catch {
657
+ authorizationOk = false;
658
+ }
659
+ const bad = [];
660
+ if (!authorizationOk) bad.push("authorization_endpoint");
661
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
662
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
663
+ if (d.jwks_uri && !sameOrigin(d.jwks_uri)) bad.push("jwks_uri");
664
+ if (d.revocation_endpoint && !sameOrigin(d.revocation_endpoint)) {
665
+ bad.push("revocation_endpoint");
666
+ }
667
+ if (d.introspection_endpoint && !sameOrigin(d.introspection_endpoint)) {
668
+ bad.push("introspection_endpoint");
669
+ }
670
+ if (d.end_session_endpoint) {
671
+ try {
672
+ if (new URL(d.end_session_endpoint).origin !== publicOrigin) {
673
+ bad.push("end_session_endpoint");
674
+ }
675
+ } catch {
676
+ bad.push("end_session_endpoint");
677
+ }
678
+ }
679
+ if (bad.length > 0) {
680
+ if (!this._discoveryOriginWarned) {
681
+ this._discoveryOriginWarned = true;
682
+ console.warn(
683
+ `[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`
684
+ );
685
+ }
686
+ return null;
687
+ }
688
+ return d;
689
+ }
625
690
  /**
626
691
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
627
692
  *
@@ -636,19 +701,17 @@ var _SsoClient = class _SsoClient {
636
701
  const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
637
702
  try {
638
703
  const controller = new AbortController();
639
- const timeoutId = setTimeout(
640
- () => controller.abort(),
641
- _SsoClient.DISCOVERY_TIMEOUT_MS
642
- );
704
+ const timeoutId = setTimeout(() => controller.abort(), _SsoClient.DISCOVERY_TIMEOUT_MS);
643
705
  const res = await fetch(url, { signal: controller.signal });
644
706
  clearTimeout(timeoutId);
645
707
  if (!res.ok) {
646
- console.warn(
647
- `[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
648
- );
708
+ console.warn(`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`);
649
709
  return null;
650
710
  }
651
- this._discovery = await res.json();
711
+ const doc = await res.json();
712
+ const sanitized = this._sanitizeDiscovery(doc);
713
+ if (!sanitized) return null;
714
+ this._discovery = sanitized;
652
715
  this._discoveryFetchedAt = now;
653
716
  return this._discovery;
654
717
  } catch (err) {
@@ -857,10 +920,7 @@ var _SsoClient = class _SsoClient {
857
920
  removeOAuthState(this.config.clientId);
858
921
  removeOAuthNonce(this.config.clientId);
859
922
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
860
- throw new SsoError(
861
- "popup_blocked",
862
- "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
863
- );
923
+ throw new SsoError("popup_blocked", "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5");
864
924
  }
865
925
  const redirectUriOrigin = new URL(this.config.redirectUri).origin;
866
926
  try {
@@ -889,9 +949,7 @@ var _SsoClient = class _SsoClient {
889
949
  }
890
950
  this.handleCallback(event.data.callbackUrl).then((tokenData) => {
891
951
  if (tokenData === null) {
892
- reject(
893
- new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
894
- );
952
+ reject(new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09"));
895
953
  return;
896
954
  }
897
955
  resolve(tokenData);
@@ -902,12 +960,7 @@ var _SsoClient = class _SsoClient {
902
960
  if (!completed) {
903
961
  completed = true;
904
962
  cleanup();
905
- reject(
906
- new SsoError(
907
- "popup_closed",
908
- "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
909
- )
910
- );
963
+ reject(new SsoError("popup_closed", "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"));
911
964
  }
912
965
  }
913
966
  }, 500);
@@ -974,18 +1027,24 @@ var _SsoClient = class _SsoClient {
974
1027
  removeOAuthNonce(this.config.clientId);
975
1028
  removePkceVerifier(this.config.clientId);
976
1029
  removeSilentProbe(this.config.clientId);
977
- throw new SsoError(
978
- "state_mismatch",
979
- "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
980
- );
1030
+ throw new SsoError("state_mismatch", "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB");
1031
+ }
1032
+ const returnedIss = params.get("iss");
1033
+ if (returnedIss) {
1034
+ const discovery = await this._getDiscovery();
1035
+ const expectedIssuer = (discovery?.issuer || this.config.ssoBaseUrl).replace(/\/+$/, "");
1036
+ if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
1037
+ removeOAuthState(this.config.clientId);
1038
+ removeOAuthNonce(this.config.clientId);
1039
+ removePkceVerifier(this.config.clientId);
1040
+ removeSilentProbe(this.config.clientId);
1041
+ throw new SsoError("issuer_mismatch", "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 IdP \u6DF7\u53E0\u653B\u51FB");
1042
+ }
981
1043
  }
982
1044
  const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
983
1045
  const verifier = getPkceVerifier(this.config.clientId);
984
1046
  if (!verifier) {
985
- throw new SsoError(
986
- "pkce_required",
987
- "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
988
- );
1047
+ 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");
989
1048
  }
990
1049
  const tokenEndpoint = await this._getTokenEndpoint();
991
1050
  const body = new URLSearchParams();
@@ -1008,6 +1067,10 @@ var _SsoClient = class _SsoClient {
1008
1067
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
1009
1068
  }
1010
1069
  if (!res.ok) {
1070
+ removeOAuthState(this.config.clientId);
1071
+ removeOAuthNonce(this.config.clientId);
1072
+ removePkceVerifier(this.config.clientId);
1073
+ removeSilentProbe(this.config.clientId);
1011
1074
  let errData = {};
1012
1075
  try {
1013
1076
  errData = await res.json();
@@ -1023,11 +1086,12 @@ var _SsoClient = class _SsoClient {
1023
1086
  assertValidTokenResponse(data, true);
1024
1087
  const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1025
1088
  if (requestedScopes.includes("openid") && !data.id_token) {
1089
+ removeOAuthState(this.config.clientId);
1090
+ removeOAuthNonce(this.config.clientId);
1091
+ removePkceVerifier(this.config.clientId);
1092
+ removeSilentProbe(this.config.clientId);
1026
1093
  removeTokenData(this.config.clientId);
1027
- throw new SsoError(
1028
- "id_token_invalid",
1029
- "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1030
- );
1094
+ throw new SsoError("id_token_invalid", "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09");
1031
1095
  }
1032
1096
  if (data.id_token) {
1033
1097
  try {
@@ -1092,19 +1156,14 @@ var _SsoClient = class _SsoClient {
1092
1156
  }
1093
1157
  let res = null;
1094
1158
  let lastErr;
1095
- for (let attempt = 0; attempt < 2; attempt++) {
1096
- try {
1097
- res = await fetchWithTimeout(tokenEndpoint, {
1098
- method: "POST",
1099
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1100
- body: body.toString()
1101
- });
1102
- lastErr = null;
1103
- break;
1104
- } catch (err) {
1105
- lastErr = err;
1106
- if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
1107
- }
1159
+ try {
1160
+ res = await fetchWithTimeout(tokenEndpoint, {
1161
+ method: "POST",
1162
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1163
+ body: body.toString()
1164
+ });
1165
+ } catch (err) {
1166
+ lastErr = err;
1108
1167
  }
1109
1168
  if (lastErr || !res) {
1110
1169
  throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
@@ -1167,7 +1226,7 @@ var _SsoClient = class _SsoClient {
1167
1226
  if (!tokenData) {
1168
1227
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1169
1228
  }
1170
- if (Date.now() >= tokenData.expires_at) {
1229
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1171
1230
  tokenData = await this.refreshToken();
1172
1231
  }
1173
1232
  const userinfoEndpoint = await this._getUserinfoEndpoint();
@@ -1186,10 +1245,7 @@ var _SsoClient = class _SsoClient {
1186
1245
  removeTokenData(this.config.clientId);
1187
1246
  throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
1188
1247
  }
1189
- throw new SsoError(
1190
- "userinfo_failed",
1191
- `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
1192
- );
1248
+ throw new SsoError("userinfo_failed", `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`);
1193
1249
  }
1194
1250
  return await res.json();
1195
1251
  }
@@ -1202,7 +1258,7 @@ var _SsoClient = class _SsoClient {
1202
1258
  async getAccessToken() {
1203
1259
  let tokenData = getTokenData(this.config.clientId);
1204
1260
  if (!tokenData) return null;
1205
- if (Date.now() >= tokenData.expires_at) {
1261
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1206
1262
  tokenData = await this.refreshToken();
1207
1263
  }
1208
1264
  return tokenData.access_token;
@@ -1211,6 +1267,9 @@ var _SsoClient = class _SsoClient {
1211
1267
  * 检查是否已认证(不发起网络请求)
1212
1268
  *
1213
1269
  * 仅检查本地是否存在未过期的 access_token。
1270
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1271
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1272
+ * getAccessToken/getUserInfo 已带提前量。
1214
1273
  */
1215
1274
  isAuthenticated() {
1216
1275
  const tokenData = getTokenData(this.config.clientId);
@@ -1246,7 +1305,7 @@ var _SsoClient = class _SsoClient {
1246
1305
  if (this.config.clientSecret) {
1247
1306
  revokeBody.set("client_secret", this.config.clientSecret);
1248
1307
  }
1249
- await fetchWithTimeout(
1308
+ const doRevoke = () => fetchWithTimeout(
1250
1309
  revokeUrl,
1251
1310
  {
1252
1311
  method: "POST",
@@ -1255,6 +1314,14 @@ var _SsoClient = class _SsoClient {
1255
1314
  },
1256
1315
  REVOKE_TIMEOUT_MS
1257
1316
  );
1317
+ let res = await doRevoke();
1318
+ if (!res.ok && (res.status >= 500 || res.status === 429)) {
1319
+ await new Promise((resolve) => setTimeout(resolve, 300));
1320
+ res = await doRevoke();
1321
+ }
1322
+ if (!res.ok) {
1323
+ this.debugLog("logout: \u670D\u52A1\u7AEF\u64A4\u9500 refresh_token \u5931\u8D25", { status: res.status });
1324
+ }
1258
1325
  } catch {
1259
1326
  }
1260
1327
  }
@@ -1265,10 +1332,7 @@ var _SsoClient = class _SsoClient {
1265
1332
  logoutUrl.searchParams.set("client_id", this.config.clientId);
1266
1333
  const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1267
1334
  if (postLogoutUri) {
1268
- logoutUrl.searchParams.set(
1269
- "post_logout_redirect_uri",
1270
- postLogoutUri
1271
- );
1335
+ logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutUri);
1272
1336
  }
1273
1337
  if (idTokenHint) {
1274
1338
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
@@ -1326,11 +1390,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1326
1390
  var LOCK_TTL_MS = 5e3;
1327
1391
  var CHANNEL_PREFIX = "nihplod_sso_events:";
1328
1392
  var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1329
- function broadcastSsoEvent(clientId, type) {
1393
+ function broadcastSsoEvent(clientId, type, tokenData) {
1330
1394
  if (typeof BroadcastChannel === "undefined") return;
1331
1395
  try {
1332
1396
  const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1333
- channel.postMessage({ type, sourceTabId: TAB_ID });
1397
+ channel.postMessage({
1398
+ type,
1399
+ sourceTabId: TAB_ID,
1400
+ ...type === "token" && tokenData ? { tokenData } : {}
1401
+ });
1334
1402
  channel.close();
1335
1403
  } catch {
1336
1404
  }
@@ -1466,7 +1534,7 @@ function SsoProvider({
1466
1534
  const td = await client.refreshToken();
1467
1535
  refreshFailureCountRef.current = 0;
1468
1536
  onTokenRefreshed?.(td.access_token);
1469
- broadcastSsoEvent(client.config.clientId, "token");
1537
+ broadcastSsoEvent(client.config.clientId, "token", td);
1470
1538
  loadUser();
1471
1539
  } catch (err) {
1472
1540
  if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
@@ -1494,7 +1562,15 @@ function SsoProvider({
1494
1562
  const tokenData = getTokenData(client.config.clientId);
1495
1563
  if (!tokenData) return;
1496
1564
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
1497
- if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1565
+ const lifetimeSec = Math.max(
1566
+ 1,
1567
+ (tokenData.expires_at - tokenData.issued_at) / 1e3
1568
+ );
1569
+ const effectiveThreshold = Math.max(
1570
+ 1,
1571
+ Math.min(refreshThreshold, Math.floor(lifetimeSec / 3))
1572
+ );
1573
+ if (remainingSec <= 0 || remainingSec <= effectiveThreshold) {
1498
1574
  refreshTimerRef.current = setTimeout(
1499
1575
  () => {
1500
1576
  if (active) void attemptRefresh();
@@ -1503,13 +1579,13 @@ function SsoProvider({
1503
1579
  );
1504
1580
  return;
1505
1581
  }
1506
- const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1582
+ const delayMs = Math.max((remainingSec - effectiveThreshold) * 1e3, minDelayMs, 1e3);
1507
1583
  refreshTimerRef.current = setTimeout(() => {
1508
1584
  if (!active) return;
1509
1585
  const td = getTokenData(client.config.clientId);
1510
1586
  if (!td) return;
1511
1587
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1512
- if (secLeft <= refreshThreshold) {
1588
+ if (secLeft <= effectiveThreshold) {
1513
1589
  void attemptRefresh();
1514
1590
  } else {
1515
1591
  scheduleNextRefresh();
@@ -1524,7 +1600,7 @@ function SsoProvider({
1524
1600
  refreshTimerRef.current = null;
1525
1601
  }
1526
1602
  };
1527
- }, [client, loadUser, refreshThreshold, onTokenRefreshed]);
1603
+ }, [client, loadUser, refreshThreshold, onTokenRefreshed, markSessionExpired]);
1528
1604
  (0, import_react.useEffect)(() => {
1529
1605
  const handleStorageChange = (e) => {
1530
1606
  if (e.key && e.key.startsWith("nihplod_sso_") && !e.key.startsWith(REFRESH_LOCK_PREFIX)) {
@@ -1549,6 +1625,10 @@ function SsoProvider({
1549
1625
  sessionExpiredNotifiedRef.current = false;
1550
1626
  refreshFailureCountRef.current = 0;
1551
1627
  } else if (data.type === "token") {
1628
+ const td = data.tokenData;
1629
+ if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
1630
+ saveTokenData(td, client.config.clientId);
1631
+ }
1552
1632
  loadUser();
1553
1633
  }
1554
1634
  };
@@ -1615,6 +1695,26 @@ function useSso() {
1615
1695
 
1616
1696
  // src/react/RequireAuth.tsx
1617
1697
  var import_react2 = __toESM(require("react"));
1698
+ function LoginErrorContent({
1699
+ loginError,
1700
+ retry,
1701
+ renderLoginError
1702
+ }) {
1703
+ if (renderLoginError) {
1704
+ return import_react2.default.createElement(
1705
+ import_react2.default.Fragment,
1706
+ null,
1707
+ renderLoginError(loginError, retry)
1708
+ );
1709
+ }
1710
+ const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1711
+ return import_react2.default.createElement(
1712
+ "div",
1713
+ null,
1714
+ import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1715
+ import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1716
+ );
1717
+ }
1618
1718
  function RequireAuth({
1619
1719
  children,
1620
1720
  fallback,
@@ -1660,20 +1760,11 @@ function RequireAuth({
1660
1760
  }
1661
1761
  if (!isAuthenticated) {
1662
1762
  if (loginError) {
1663
- if (renderLoginError) {
1664
- return import_react2.default.createElement(
1665
- import_react2.default.Fragment,
1666
- null,
1667
- renderLoginError(loginError, retry)
1668
- );
1669
- }
1670
- const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1671
- return import_react2.default.createElement(
1672
- "div",
1673
- null,
1674
- import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1675
- import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1676
- );
1763
+ return import_react2.default.createElement(LoginErrorContent, {
1764
+ loginError,
1765
+ retry,
1766
+ renderLoginError
1767
+ });
1677
1768
  }
1678
1769
  if (fallback) return import_react2.default.createElement(import_react2.default.Fragment, null, fallback);
1679
1770
  return import_react2.default.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");