@nihplod/sso-sdk 1.4.1 → 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.
@@ -135,6 +135,9 @@ function createMemoryStorageAdapter() {
135
135
  },
136
136
  remove(key) {
137
137
  store.delete(key);
138
+ },
139
+ keys() {
140
+ return [...store.keys()];
138
141
  }
139
142
  };
140
143
  }
@@ -143,26 +146,44 @@ function createSessionStorageAdapter() {
143
146
  const fallback = /* @__PURE__ */ new Map();
144
147
  return {
145
148
  get(key) {
146
- if (typeof sessionStorage !== "undefined") {
147
- return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
149
+ try {
150
+ if (typeof sessionStorage !== "undefined") {
151
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
152
+ }
153
+ } catch {
148
154
  }
149
155
  return fallback.get(key) ?? null;
150
156
  },
151
157
  set(key, value) {
152
- if (typeof sessionStorage === "undefined") {
153
- fallback.set(key, value);
154
- return;
155
- }
156
158
  try {
157
- sessionStorage.setItem(STORAGE_PREFIX + key, value);
159
+ if (typeof sessionStorage !== "undefined") {
160
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
161
+ return;
162
+ }
158
163
  } catch {
159
- fallback.set(key, value);
160
164
  }
165
+ fallback.set(key, value);
161
166
  },
162
167
  remove(key) {
163
168
  fallback.delete(key);
164
- if (typeof sessionStorage === "undefined") return;
165
- sessionStorage.removeItem(STORAGE_PREFIX + key);
169
+ try {
170
+ if (typeof sessionStorage === "undefined") return;
171
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
172
+ } catch {
173
+ }
174
+ },
175
+ keys() {
176
+ const result = new Set(fallback.keys());
177
+ try {
178
+ if (typeof sessionStorage !== "undefined") {
179
+ for (let i = 0; i < sessionStorage.length; i++) {
180
+ const key = sessionStorage.key(i);
181
+ if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
182
+ }
183
+ }
184
+ } catch {
185
+ }
186
+ return [...result];
166
187
  }
167
188
  };
168
189
  }
@@ -255,32 +276,58 @@ function clearAllSsoData(clientId) {
255
276
  removeReturnUrl();
256
277
  removeLogoutState();
257
278
  removeSilentProbe();
258
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
259
- const stores = [
260
- typeof sessionStorage !== "undefined" ? sessionStorage : null,
261
- typeof localStorage !== "undefined" ? localStorage : null
262
- ];
279
+ const stores = [];
280
+ try {
281
+ if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
282
+ } catch {
283
+ }
284
+ try {
285
+ if (typeof localStorage !== "undefined") stores.push(localStorage);
286
+ } catch {
287
+ }
288
+ const abstractKeys = /* @__PURE__ */ new Set();
263
289
  for (const store of stores) {
264
- if (!store) continue;
265
290
  const keys = [];
266
- for (let i = 0; i < store.length; i++) {
267
- const key = store.key(i);
268
- if (key?.startsWith(prefix)) keys.push(key);
291
+ try {
292
+ for (let i = 0; i < store.length; i++) {
293
+ const key = store.key(i);
294
+ if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
295
+ }
296
+ } catch {
297
+ continue;
269
298
  }
270
299
  for (const key of keys) {
271
- _transient.remove(key.slice(STORAGE_PREFIX.length));
272
- store.removeItem(key);
300
+ abstractKeys.add(key.slice(STORAGE_PREFIX.length));
301
+ try {
302
+ store.removeItem(key);
303
+ } catch {
304
+ }
305
+ }
306
+ }
307
+ for (const store of [_transient, _storage]) {
308
+ try {
309
+ for (const key of store.keys?.() ?? []) abstractKeys.add(key);
310
+ } catch {
273
311
  }
274
312
  }
313
+ for (const key of abstractKeys) {
314
+ _transient.remove(key);
315
+ _storage.remove(key);
316
+ }
275
317
  }
276
318
 
277
319
  // src/core/security.ts
320
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
278
321
  function isTrustedReturnUrl(url, currentOrigin) {
279
322
  if (!url) return false;
280
- if (url.includes("\\")) return false;
281
- if (url.startsWith("/") && !url.startsWith("//")) return true;
323
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
324
+ if (url.startsWith("//")) return false;
325
+ if (url.startsWith("/")) return true;
282
326
  try {
283
- return new URL(url).origin === currentOrigin;
327
+ const parsed = new URL(url);
328
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
329
+ if (parsed.username || parsed.password) return false;
330
+ return parsed.origin === currentOrigin;
284
331
  } catch {
285
332
  return false;
286
333
  }
@@ -492,7 +539,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
492
539
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
493
540
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
494
541
  }
495
- 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) {
496
546
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
497
547
  }
498
548
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -509,11 +559,47 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
509
559
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
510
560
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
511
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
+ );
512
567
  }
513
568
  return { sub: payload.sub };
514
569
  }
515
570
 
571
+ // src/core/http.ts
572
+ var REQUEST_TIMEOUT_MS = 1e4;
573
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
574
+ const controller = new AbortController();
575
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
576
+ try {
577
+ return await fetch(input, { ...init, signal: controller.signal });
578
+ } finally {
579
+ clearTimeout(timer);
580
+ }
581
+ }
582
+
516
583
  // src/core/SsoClient.ts
584
+ var REVOKE_TIMEOUT_MS = 3e3;
585
+ var EXPIRY_SKEW_MS = 1e4;
586
+ function assertValidTokenResponse(data, requireRefreshToken) {
587
+ if (typeof data.access_token !== "string" || !data.access_token) {
588
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
589
+ }
590
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
591
+ throw new SsoError(
592
+ "token_request_failed",
593
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
594
+ );
595
+ }
596
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
597
+ throw new SsoError(
598
+ "token_request_failed",
599
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
600
+ );
601
+ }
602
+ }
517
603
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
518
604
  "login_required",
519
605
  "consent_required",
@@ -524,6 +610,8 @@ var _SsoClient = class _SsoClient {
524
610
  this._discovery = null;
525
611
  this._discoveryFetchedAt = 0;
526
612
  this._refreshLock = null;
613
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
614
+ this._discoveryOriginWarned = false;
527
615
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
528
616
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
529
617
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -532,8 +620,67 @@ var _SsoClient = class _SsoClient {
532
620
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
533
621
  }
534
622
  // ============================================
623
+ // 调试日志
624
+ // ============================================
625
+ /**
626
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
627
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
628
+ * 不会输出 token/授权码等敏感值。
629
+ */
630
+ debugLog(...args) {
631
+ if (this.config.debug) {
632
+ console.warn("[SSO SDK]", ...args);
633
+ }
634
+ }
635
+ // ============================================
535
636
  // 内部方法
536
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
+ }
537
684
  /**
538
685
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
539
686
  *
@@ -560,7 +707,10 @@ var _SsoClient = class _SsoClient {
560
707
  );
561
708
  return null;
562
709
  }
563
- 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;
564
714
  this._discoveryFetchedAt = now;
565
715
  return this._discovery;
566
716
  } catch (err) {
@@ -911,7 +1061,7 @@ var _SsoClient = class _SsoClient {
911
1061
  }
912
1062
  let res;
913
1063
  try {
914
- res = await fetch(tokenEndpoint, {
1064
+ res = await fetchWithTimeout(tokenEndpoint, {
915
1065
  method: "POST",
916
1066
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
917
1067
  body: body.toString()
@@ -920,6 +1070,10 @@ var _SsoClient = class _SsoClient {
920
1070
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
921
1071
  }
922
1072
  if (!res.ok) {
1073
+ removeOAuthState(this.config.clientId);
1074
+ removeOAuthNonce(this.config.clientId);
1075
+ removePkceVerifier(this.config.clientId);
1076
+ removeSilentProbe(this.config.clientId);
923
1077
  let errData = {};
924
1078
  try {
925
1079
  errData = await res.json();
@@ -932,6 +1086,19 @@ var _SsoClient = class _SsoClient {
932
1086
  );
933
1087
  }
934
1088
  const data = await res.json();
1089
+ assertValidTokenResponse(data, true);
1090
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
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);
1096
+ removeTokenData(this.config.clientId);
1097
+ throw new SsoError(
1098
+ "id_token_invalid",
1099
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1100
+ );
1101
+ }
935
1102
  if (data.id_token) {
936
1103
  try {
937
1104
  await validateIdToken(
@@ -961,6 +1128,7 @@ var _SsoClient = class _SsoClient {
961
1128
  expires_at: now + data.expires_in * 1e3
962
1129
  };
963
1130
  saveTokenData(tokenData, this.config.clientId);
1131
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
964
1132
  return tokenData;
965
1133
  }
966
1134
  /**
@@ -996,7 +1164,7 @@ var _SsoClient = class _SsoClient {
996
1164
  let lastErr;
997
1165
  for (let attempt = 0; attempt < 2; attempt++) {
998
1166
  try {
999
- res = await fetch(tokenEndpoint, {
1167
+ res = await fetchWithTimeout(tokenEndpoint, {
1000
1168
  method: "POST",
1001
1169
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1002
1170
  body: body.toString()
@@ -1018,15 +1186,20 @@ var _SsoClient = class _SsoClient {
1018
1186
  } catch {
1019
1187
  }
1020
1188
  const errorCode = errData.error || "";
1021
- if (errorCode === "invalid_grant" || res.status === 401) {
1189
+ if (errorCode === "invalid_grant") {
1022
1190
  removeTokenData(this.config.clientId);
1023
1191
  }
1192
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1193
+ if (!errorCode && res.status === 401) {
1194
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1195
+ }
1024
1196
  throw new SsoError(
1025
1197
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
1026
1198
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
1027
1199
  );
1028
1200
  }
1029
1201
  const data = await res.json();
1202
+ assertValidTokenResponse(data, false);
1030
1203
  if (data.id_token) {
1031
1204
  try {
1032
1205
  await validateIdToken(
@@ -1064,13 +1237,13 @@ var _SsoClient = class _SsoClient {
1064
1237
  if (!tokenData) {
1065
1238
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1066
1239
  }
1067
- if (Date.now() >= tokenData.expires_at) {
1240
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1068
1241
  tokenData = await this.refreshToken();
1069
1242
  }
1070
1243
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1071
1244
  let res;
1072
1245
  try {
1073
- res = await fetch(userinfoEndpoint, {
1246
+ res = await fetchWithTimeout(userinfoEndpoint, {
1074
1247
  headers: {
1075
1248
  Authorization: `Bearer ${tokenData.access_token}`
1076
1249
  }
@@ -1099,7 +1272,7 @@ var _SsoClient = class _SsoClient {
1099
1272
  async getAccessToken() {
1100
1273
  let tokenData = getTokenData(this.config.clientId);
1101
1274
  if (!tokenData) return null;
1102
- if (Date.now() >= tokenData.expires_at) {
1275
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1103
1276
  tokenData = await this.refreshToken();
1104
1277
  }
1105
1278
  return tokenData.access_token;
@@ -1108,6 +1281,9 @@ var _SsoClient = class _SsoClient {
1108
1281
  * 检查是否已认证(不发起网络请求)
1109
1282
  *
1110
1283
  * 仅检查本地是否存在未过期的 access_token。
1284
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1285
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1286
+ * getAccessToken/getUserInfo 已带提前量。
1111
1287
  */
1112
1288
  isAuthenticated() {
1113
1289
  const tokenData = getTokenData(this.config.clientId);
@@ -1127,6 +1303,10 @@ var _SsoClient = class _SsoClient {
1127
1303
  const refreshToken = tokenData?.refresh_token;
1128
1304
  const idTokenHint = tokenData?.id_token;
1129
1305
  clearAllSsoData(this.config.clientId);
1306
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1307
+ clientId: this.config.clientId,
1308
+ redirectToSso
1309
+ });
1130
1310
  if (refreshToken && this.config.clientId) {
1131
1311
  try {
1132
1312
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1139,11 +1319,15 @@ var _SsoClient = class _SsoClient {
1139
1319
  if (this.config.clientSecret) {
1140
1320
  revokeBody.set("client_secret", this.config.clientSecret);
1141
1321
  }
1142
- await fetch(revokeUrl, {
1143
- method: "POST",
1144
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1145
- body: revokeBody.toString()
1146
- });
1322
+ await fetchWithTimeout(
1323
+ revokeUrl,
1324
+ {
1325
+ method: "POST",
1326
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1327
+ body: revokeBody.toString()
1328
+ },
1329
+ REVOKE_TIMEOUT_MS
1330
+ );
1147
1331
  } catch {
1148
1332
  }
1149
1333
  }
@@ -1213,6 +1397,21 @@ var SsoClient = _SsoClient;
1213
1397
  var SsoContext = (0, import_react.createContext)(null);
1214
1398
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1215
1399
  var LOCK_TTL_MS = 5e3;
1400
+ var CHANNEL_PREFIX = "nihplod_sso_events:";
1401
+ var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1402
+ function broadcastSsoEvent(clientId, type, tokenData) {
1403
+ if (typeof BroadcastChannel === "undefined") return;
1404
+ try {
1405
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1406
+ channel.postMessage({
1407
+ type,
1408
+ sourceTabId: TAB_ID,
1409
+ ...type === "token" && tokenData ? { tokenData } : {}
1410
+ });
1411
+ channel.close();
1412
+ } catch {
1413
+ }
1414
+ }
1216
1415
  var ownedLocks = /* @__PURE__ */ new Map();
1217
1416
  function lockKey(clientId) {
1218
1417
  return REFRESH_LOCK_PREFIX + clientId;
@@ -1271,21 +1470,37 @@ function SsoProvider({
1271
1470
  config,
1272
1471
  children,
1273
1472
  refreshThreshold = 60,
1274
- onTokenRefreshed
1473
+ onTokenRefreshed,
1474
+ onSessionExpired
1275
1475
  }) {
1276
1476
  const [user, setUser] = (0, import_react.useState)(null);
1277
1477
  const [isLoading, setIsLoading] = (0, import_react.useState)(true);
1278
1478
  const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
1279
1479
  const [error, setError] = (0, import_react.useState)(null);
1480
+ const [sessionExpired, setSessionExpired] = (0, import_react.useState)(false);
1280
1481
  const [client] = (0, import_react.useState)(() => new SsoClient(config));
1281
1482
  const refreshTimerRef = (0, import_react.useRef)(null);
1282
1483
  const loadedRef = (0, import_react.useRef)(false);
1484
+ const onSessionExpiredRef = (0, import_react.useRef)(onSessionExpired);
1485
+ (0, import_react.useEffect)(() => {
1486
+ onSessionExpiredRef.current = onSessionExpired;
1487
+ });
1488
+ const sessionExpiredNotifiedRef = (0, import_react.useRef)(false);
1489
+ const refreshFailureCountRef = (0, import_react.useRef)(0);
1490
+ const MAX_REFRESH_FAILURES = 3;
1491
+ const markSessionExpired = (0, import_react.useCallback)((error2) => {
1492
+ setError(error2);
1493
+ setSessionExpired(true);
1494
+ if (!sessionExpiredNotifiedRef.current) {
1495
+ sessionExpiredNotifiedRef.current = true;
1496
+ onSessionExpiredRef.current?.(error2);
1497
+ }
1498
+ }, []);
1283
1499
  const loadUser = (0, import_react.useCallback)(async () => {
1284
1500
  const tokenData = getTokenData(client.config.clientId);
1285
1501
  if (!tokenData) {
1286
1502
  setUser(null);
1287
1503
  setIsAuthenticated(false);
1288
- setError(null);
1289
1504
  setIsLoading(false);
1290
1505
  return;
1291
1506
  }
@@ -1294,19 +1509,22 @@ function SsoProvider({
1294
1509
  setUser(u);
1295
1510
  setIsAuthenticated(true);
1296
1511
  setError(null);
1512
+ setSessionExpired(false);
1513
+ sessionExpiredNotifiedRef.current = false;
1514
+ refreshFailureCountRef.current = 0;
1297
1515
  } catch (err) {
1298
1516
  setUser(null);
1299
1517
  setIsAuthenticated(false);
1300
- setError(
1301
- err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1302
- );
1303
- if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1518
+ const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
1519
+ setError(errorObj);
1520
+ if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
1304
1521
  removeTokenData(client.config.clientId);
1522
+ markSessionExpired(errorObj);
1305
1523
  }
1306
1524
  } finally {
1307
1525
  setIsLoading(false);
1308
1526
  }
1309
- }, [client]);
1527
+ }, [client, markSessionExpired]);
1310
1528
  (0, import_react.useEffect)(() => {
1311
1529
  if (loadedRef.current) return;
1312
1530
  loadedRef.current = true;
@@ -1323,13 +1541,28 @@ function SsoProvider({
1323
1541
  const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1324
1542
  try {
1325
1543
  const td = await client.refreshToken();
1544
+ refreshFailureCountRef.current = 0;
1326
1545
  onTokenRefreshed?.(td.access_token);
1546
+ broadcastSsoEvent(client.config.clientId, "token", td);
1327
1547
  loadUser();
1328
- } catch {
1548
+ } catch (err) {
1549
+ if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
1550
+ removeTokenData(client.config.clientId);
1551
+ markSessionExpired(err);
1552
+ } else {
1553
+ refreshFailureCountRef.current += 1;
1554
+ if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
1555
+ markSessionExpired(
1556
+ err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
1557
+ );
1558
+ }
1559
+ }
1329
1560
  setTimeout(() => loadUser(), 500);
1330
1561
  }
1562
+ }).finally(() => {
1563
+ if (active) scheduleNextRefresh(5e3);
1331
1564
  });
1332
- const scheduleNextRefresh = () => {
1565
+ const scheduleNextRefresh = (minDelayMs = 0) => {
1333
1566
  if (!active) return;
1334
1567
  if (refreshTimerRef.current) {
1335
1568
  clearTimeout(refreshTimerRef.current);
@@ -1338,26 +1571,27 @@ function SsoProvider({
1338
1571
  const tokenData = getTokenData(client.config.clientId);
1339
1572
  if (!tokenData) return;
1340
1573
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
1341
- if (remainingSec <= 0) {
1342
- void attemptRefresh();
1343
- return;
1344
- }
1345
- if (remainingSec <= refreshThreshold) {
1346
- void attemptRefresh();
1574
+ if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1575
+ refreshTimerRef.current = setTimeout(
1576
+ () => {
1577
+ if (active) void attemptRefresh();
1578
+ },
1579
+ Math.max(minDelayMs, 1e3)
1580
+ );
1347
1581
  return;
1348
1582
  }
1349
- const delayMs = (remainingSec - refreshThreshold) * 1e3;
1583
+ const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1350
1584
  refreshTimerRef.current = setTimeout(() => {
1351
1585
  if (!active) return;
1352
1586
  const td = getTokenData(client.config.clientId);
1353
1587
  if (!td) return;
1354
1588
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1355
1589
  if (secLeft <= refreshThreshold) {
1356
- void attemptRefresh().then((didRefresh) => {
1357
- if (didRefresh) scheduleNextRefresh();
1358
- });
1590
+ void attemptRefresh();
1591
+ } else {
1592
+ scheduleNextRefresh();
1359
1593
  }
1360
- }, Math.max(delayMs, 1e3));
1594
+ }, delayMs);
1361
1595
  };
1362
1596
  scheduleNextRefresh();
1363
1597
  return () => {
@@ -1377,6 +1611,30 @@ function SsoProvider({
1377
1611
  window.addEventListener("storage", handleStorageChange);
1378
1612
  return () => window.removeEventListener("storage", handleStorageChange);
1379
1613
  }, [loadUser]);
1614
+ (0, import_react.useEffect)(() => {
1615
+ if (typeof BroadcastChannel === "undefined") return;
1616
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
1617
+ channel.onmessage = (event) => {
1618
+ const data = event.data;
1619
+ if (!data || data.sourceTabId === TAB_ID) return;
1620
+ if (data.type === "logout") {
1621
+ removeTokenData(client.config.clientId);
1622
+ setUser(null);
1623
+ setIsAuthenticated(false);
1624
+ setError(null);
1625
+ setSessionExpired(false);
1626
+ sessionExpiredNotifiedRef.current = false;
1627
+ refreshFailureCountRef.current = 0;
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
+ }
1633
+ loadUser();
1634
+ }
1635
+ };
1636
+ return () => channel.close();
1637
+ }, [client, loadUser]);
1380
1638
  const login = (0, import_react.useCallback)(
1381
1639
  async (returnUrl) => {
1382
1640
  await client.login(returnUrl);
@@ -1393,10 +1651,17 @@ function SsoProvider({
1393
1651
  );
1394
1652
  const logout = (0, import_react.useCallback)(
1395
1653
  async (redirectToSso = false) => {
1396
- await client.logout(redirectToSso);
1397
1654
  setUser(null);
1398
1655
  setIsAuthenticated(false);
1399
1656
  setError(null);
1657
+ setSessionExpired(false);
1658
+ sessionExpiredNotifiedRef.current = false;
1659
+ refreshFailureCountRef.current = 0;
1660
+ broadcastSsoEvent(client.config.clientId, "logout");
1661
+ try {
1662
+ await client.logout(redirectToSso);
1663
+ } catch {
1664
+ }
1400
1665
  },
1401
1666
  [client]
1402
1667
  );
@@ -1416,6 +1681,7 @@ function SsoProvider({
1416
1681
  logout,
1417
1682
  refreshUser,
1418
1683
  getAccessToken,
1684
+ sessionExpired,
1419
1685
  client
1420
1686
  };
1421
1687
  return import_react.default.createElement(SsoContext.Provider, { value }, children);
@@ -1510,6 +1776,36 @@ function withAuth(Component) {
1510
1776
 
1511
1777
  // src/react/CallbackPage.tsx
1512
1778
  var import_react3 = __toESM(require("react"));
1779
+ var CALLBACK_ERROR_HINTS = {
1780
+ state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1781
+ pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1782
+ user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
1783
+ session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1784
+ authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1785
+ authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1786
+ token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1787
+ userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1788
+ not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
1789
+ network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
1790
+ rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1791
+ popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
1792
+ popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1793
+ client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1794
+ account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
1795
+ sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1796
+ invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1797
+ no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1798
+ };
1799
+ function getCallbackErrorMessage(err) {
1800
+ if (err instanceof SsoError) {
1801
+ const hint = CALLBACK_ERROR_HINTS[err.code];
1802
+ if (hint) return hint;
1803
+ if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1804
+ if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
1805
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1806
+ }
1807
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
1808
+ }
1513
1809
  function DefaultCallbackError({ error }) {
1514
1810
  return import_react3.default.createElement(
1515
1811
  "div",
@@ -1520,18 +1816,54 @@ function DefaultCallbackError({ error }) {
1520
1816
  alignItems: "center",
1521
1817
  justifyContent: "center",
1522
1818
  minHeight: "100vh",
1523
- fontFamily: "system-ui, sans-serif"
1819
+ fontFamily: "system-ui, sans-serif",
1820
+ padding: "2rem",
1821
+ textAlign: "center"
1524
1822
  }
1525
1823
  },
1526
1824
  import_react3.default.createElement(
1527
1825
  "p",
1528
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1826
+ { style: { color: "#dc2626", marginBottom: "1.5rem" } },
1529
1827
  error
1530
1828
  ),
1531
1829
  import_react3.default.createElement(
1532
- "a",
1533
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1534
- "\u8FD4\u56DE\u9996\u9875"
1830
+ "div",
1831
+ { style: { display: "flex", gap: "0.75rem" } },
1832
+ import_react3.default.createElement(
1833
+ "a",
1834
+ {
1835
+ href: "/",
1836
+ style: {
1837
+ display: "inline-block",
1838
+ padding: "0.6rem 1.4rem",
1839
+ background: "#2c2c2c",
1840
+ color: "#fff",
1841
+ fontSize: "0.8125rem",
1842
+ textDecoration: "none"
1843
+ }
1844
+ },
1845
+ "\u91CD\u65B0\u767B\u5F55"
1846
+ ),
1847
+ import_react3.default.createElement(
1848
+ "button",
1849
+ {
1850
+ type: "button",
1851
+ onClick: () => {
1852
+ if (window.history.length > 1) window.history.back();
1853
+ else window.location.href = "/";
1854
+ },
1855
+ style: {
1856
+ display: "inline-block",
1857
+ padding: "0.6rem 1.4rem",
1858
+ border: "1px solid rgba(44, 44, 44, 0.25)",
1859
+ background: "transparent",
1860
+ color: "#2c2c2c",
1861
+ fontSize: "0.8125rem",
1862
+ cursor: "pointer"
1863
+ }
1864
+ },
1865
+ "\u8FD4\u56DE\u4E0A\u4E00\u9875"
1866
+ )
1535
1867
  )
1536
1868
  );
1537
1869
  }
@@ -1546,9 +1878,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1546
1878
  onSuccessRef.current = onSuccess;
1547
1879
  onErrorRef.current = onError;
1548
1880
  });
1881
+ const callbackPromiseRef = (0, import_react3.useRef)(null);
1882
+ const postProcessedRef = (0, import_react3.useRef)(false);
1883
+ const errorHandledRef = (0, import_react3.useRef)(false);
1549
1884
  (0, import_react3.useEffect)(() => {
1550
- if (window.opener && !window.opener.closed) {
1551
- const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1885
+ const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
1886
+ if (window.opener && !window.opener.closed && popupNonce) {
1887
+ const nonce = popupNonce;
1552
1888
  let targetOrigin = window.location.origin;
1553
1889
  try {
1554
1890
  targetOrigin = window.opener.location.origin;
@@ -1557,7 +1893,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1557
1893
  const message = {
1558
1894
  type: "nihplod_sso_popup_callback",
1559
1895
  callbackUrl: window.location.href,
1560
- nonce: nonce || void 0
1896
+ nonce
1561
1897
  };
1562
1898
  let acked = false;
1563
1899
  const send = () => {
@@ -1572,9 +1908,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1572
1908
  setAwaitingManualClose(true);
1573
1909
  }, 1e4);
1574
1910
  const handleAck = (event) => {
1911
+ if (event.source !== window.opener) return;
1575
1912
  if (event.origin !== targetOrigin) return;
1576
1913
  if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1577
- if (nonce && event.data.nonce !== nonce) return;
1914
+ if (event.data.nonce !== nonce) return;
1578
1915
  acked = true;
1579
1916
  clearInterval(resendTimer);
1580
1917
  clearTimeout(ackTimeout);
@@ -1590,8 +1927,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1590
1927
  let cancelled = false;
1591
1928
  async function handleCallback() {
1592
1929
  try {
1593
- const tokenData = await client.handleCallback(window.location.href);
1594
- if (cancelled) return;
1930
+ if (!callbackPromiseRef.current) {
1931
+ callbackPromiseRef.current = client.handleCallback(window.location.href);
1932
+ }
1933
+ const tokenData = await callbackPromiseRef.current;
1934
+ if (cancelled || postProcessedRef.current) return;
1935
+ postProcessedRef.current = true;
1595
1936
  if (tokenData === null) {
1596
1937
  const clientId2 = client.config.clientId;
1597
1938
  const probeReturnUrl = getReturnUrl(clientId2);
@@ -1612,14 +1953,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1612
1953
  }
1613
1954
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1614
1955
  } catch (err) {
1615
- if (cancelled) return;
1956
+ if (cancelled || errorHandledRef.current) return;
1957
+ errorHandledRef.current = true;
1958
+ callbackPromiseRef.current = null;
1616
1959
  const errorObj = err instanceof Error ? err : new Error(String(err));
1617
1960
  onErrorRef.current?.(errorObj);
1618
- if (errorObj instanceof SsoError) {
1619
- setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
1620
- } else {
1621
- setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
1622
- }
1961
+ setError(getCallbackErrorMessage(errorObj));
1623
1962
  setProcessing(false);
1624
1963
  }
1625
1964
  }