@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.
@@ -101,6 +101,9 @@ function createMemoryStorageAdapter() {
101
101
  },
102
102
  remove(key) {
103
103
  store.delete(key);
104
+ },
105
+ keys() {
106
+ return [...store.keys()];
104
107
  }
105
108
  };
106
109
  }
@@ -109,26 +112,44 @@ function createSessionStorageAdapter() {
109
112
  const fallback = /* @__PURE__ */ new Map();
110
113
  return {
111
114
  get(key) {
112
- if (typeof sessionStorage !== "undefined") {
113
- return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
115
+ try {
116
+ if (typeof sessionStorage !== "undefined") {
117
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
118
+ }
119
+ } catch {
114
120
  }
115
121
  return fallback.get(key) ?? null;
116
122
  },
117
123
  set(key, value) {
118
- if (typeof sessionStorage === "undefined") {
119
- fallback.set(key, value);
120
- return;
121
- }
122
124
  try {
123
- sessionStorage.setItem(STORAGE_PREFIX + key, value);
125
+ if (typeof sessionStorage !== "undefined") {
126
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
127
+ return;
128
+ }
124
129
  } catch {
125
- fallback.set(key, value);
126
130
  }
131
+ fallback.set(key, value);
127
132
  },
128
133
  remove(key) {
129
134
  fallback.delete(key);
130
- if (typeof sessionStorage === "undefined") return;
131
- sessionStorage.removeItem(STORAGE_PREFIX + key);
135
+ try {
136
+ if (typeof sessionStorage === "undefined") return;
137
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
138
+ } catch {
139
+ }
140
+ },
141
+ keys() {
142
+ const result = new Set(fallback.keys());
143
+ try {
144
+ if (typeof sessionStorage !== "undefined") {
145
+ for (let i = 0; i < sessionStorage.length; i++) {
146
+ const key = sessionStorage.key(i);
147
+ if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
148
+ }
149
+ }
150
+ } catch {
151
+ }
152
+ return [...result];
132
153
  }
133
154
  };
134
155
  }
@@ -221,32 +242,58 @@ function clearAllSsoData(clientId) {
221
242
  removeReturnUrl();
222
243
  removeLogoutState();
223
244
  removeSilentProbe();
224
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
225
- const stores = [
226
- typeof sessionStorage !== "undefined" ? sessionStorage : null,
227
- typeof localStorage !== "undefined" ? localStorage : null
228
- ];
245
+ const stores = [];
246
+ try {
247
+ if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
248
+ } catch {
249
+ }
250
+ try {
251
+ if (typeof localStorage !== "undefined") stores.push(localStorage);
252
+ } catch {
253
+ }
254
+ const abstractKeys = /* @__PURE__ */ new Set();
229
255
  for (const store of stores) {
230
- if (!store) continue;
231
256
  const keys = [];
232
- for (let i = 0; i < store.length; i++) {
233
- const key = store.key(i);
234
- if (key?.startsWith(prefix)) keys.push(key);
257
+ try {
258
+ for (let i = 0; i < store.length; i++) {
259
+ const key = store.key(i);
260
+ if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
261
+ }
262
+ } catch {
263
+ continue;
235
264
  }
236
265
  for (const key of keys) {
237
- _transient.remove(key.slice(STORAGE_PREFIX.length));
238
- store.removeItem(key);
266
+ abstractKeys.add(key.slice(STORAGE_PREFIX.length));
267
+ try {
268
+ store.removeItem(key);
269
+ } catch {
270
+ }
271
+ }
272
+ }
273
+ for (const store of [_transient, _storage]) {
274
+ try {
275
+ for (const key of store.keys?.() ?? []) abstractKeys.add(key);
276
+ } catch {
239
277
  }
240
278
  }
279
+ for (const key of abstractKeys) {
280
+ _transient.remove(key);
281
+ _storage.remove(key);
282
+ }
241
283
  }
242
284
 
243
285
  // src/core/security.ts
286
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
244
287
  function isTrustedReturnUrl(url, currentOrigin) {
245
288
  if (!url) return false;
246
- if (url.includes("\\")) return false;
247
- if (url.startsWith("/") && !url.startsWith("//")) return true;
289
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
290
+ if (url.startsWith("//")) return false;
291
+ if (url.startsWith("/")) return true;
248
292
  try {
249
- return new URL(url).origin === currentOrigin;
293
+ const parsed = new URL(url);
294
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
295
+ if (parsed.username || parsed.password) return false;
296
+ return parsed.origin === currentOrigin;
250
297
  } catch {
251
298
  return false;
252
299
  }
@@ -458,7 +505,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
458
505
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
459
506
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
460
507
  }
461
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
508
+ if (typeof payload.iat !== "number") {
509
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
510
+ }
511
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
462
512
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
463
513
  }
464
514
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -475,11 +525,47 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
475
525
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
476
526
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
477
527
  }
528
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
529
+ throw new SsoError(
530
+ "id_token_nonce_mismatch",
531
+ "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"
532
+ );
478
533
  }
479
534
  return { sub: payload.sub };
480
535
  }
481
536
 
537
+ // src/core/http.ts
538
+ var REQUEST_TIMEOUT_MS = 1e4;
539
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
540
+ const controller = new AbortController();
541
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
542
+ try {
543
+ return await fetch(input, { ...init, signal: controller.signal });
544
+ } finally {
545
+ clearTimeout(timer);
546
+ }
547
+ }
548
+
482
549
  // src/core/SsoClient.ts
550
+ var REVOKE_TIMEOUT_MS = 3e3;
551
+ var EXPIRY_SKEW_MS = 1e4;
552
+ function assertValidTokenResponse(data, requireRefreshToken) {
553
+ if (typeof data.access_token !== "string" || !data.access_token) {
554
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
555
+ }
556
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
557
+ throw new SsoError(
558
+ "token_request_failed",
559
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
560
+ );
561
+ }
562
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
563
+ throw new SsoError(
564
+ "token_request_failed",
565
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
566
+ );
567
+ }
568
+ }
483
569
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
484
570
  "login_required",
485
571
  "consent_required",
@@ -490,6 +576,8 @@ var _SsoClient = class _SsoClient {
490
576
  this._discovery = null;
491
577
  this._discoveryFetchedAt = 0;
492
578
  this._refreshLock = null;
579
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
580
+ this._discoveryOriginWarned = false;
493
581
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
494
582
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
495
583
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -498,8 +586,67 @@ var _SsoClient = class _SsoClient {
498
586
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
499
587
  }
500
588
  // ============================================
589
+ // 调试日志
590
+ // ============================================
591
+ /**
592
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
593
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
594
+ * 不会输出 token/授权码等敏感值。
595
+ */
596
+ debugLog(...args) {
597
+ if (this.config.debug) {
598
+ console.warn("[SSO SDK]", ...args);
599
+ }
600
+ }
601
+ // ============================================
501
602
  // 内部方法
502
603
  // ============================================
604
+ /**
605
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
606
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
607
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
608
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
609
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
610
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
611
+ */
612
+ _sanitizeDiscovery(d) {
613
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
614
+ const allowedOrigins = /* @__PURE__ */ new Set();
615
+ try {
616
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
617
+ allowedOrigins.add(new URL(this._serverBase).origin);
618
+ } catch {
619
+ return null;
620
+ }
621
+ const sameOrigin = (endpoint) => {
622
+ try {
623
+ return allowedOrigins.has(new URL(endpoint).origin);
624
+ } catch {
625
+ return false;
626
+ }
627
+ };
628
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
629
+ let authorizationOk = false;
630
+ try {
631
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
632
+ } catch {
633
+ authorizationOk = false;
634
+ }
635
+ const bad = [];
636
+ if (!authorizationOk) bad.push("authorization_endpoint");
637
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
638
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
639
+ if (bad.length > 0) {
640
+ if (!this._discoveryOriginWarned) {
641
+ this._discoveryOriginWarned = true;
642
+ console.warn(
643
+ `[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`
644
+ );
645
+ }
646
+ return null;
647
+ }
648
+ return d;
649
+ }
503
650
  /**
504
651
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
505
652
  *
@@ -526,7 +673,10 @@ var _SsoClient = class _SsoClient {
526
673
  );
527
674
  return null;
528
675
  }
529
- this._discovery = await res.json();
676
+ const doc = await res.json();
677
+ const sanitized = this._sanitizeDiscovery(doc);
678
+ if (!sanitized) return null;
679
+ this._discovery = sanitized;
530
680
  this._discoveryFetchedAt = now;
531
681
  return this._discovery;
532
682
  } catch (err) {
@@ -877,7 +1027,7 @@ var _SsoClient = class _SsoClient {
877
1027
  }
878
1028
  let res;
879
1029
  try {
880
- res = await fetch(tokenEndpoint, {
1030
+ res = await fetchWithTimeout(tokenEndpoint, {
881
1031
  method: "POST",
882
1032
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
883
1033
  body: body.toString()
@@ -886,6 +1036,10 @@ var _SsoClient = class _SsoClient {
886
1036
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
887
1037
  }
888
1038
  if (!res.ok) {
1039
+ removeOAuthState(this.config.clientId);
1040
+ removeOAuthNonce(this.config.clientId);
1041
+ removePkceVerifier(this.config.clientId);
1042
+ removeSilentProbe(this.config.clientId);
889
1043
  let errData = {};
890
1044
  try {
891
1045
  errData = await res.json();
@@ -898,6 +1052,19 @@ var _SsoClient = class _SsoClient {
898
1052
  );
899
1053
  }
900
1054
  const data = await res.json();
1055
+ assertValidTokenResponse(data, true);
1056
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1057
+ if (requestedScopes.includes("openid") && !data.id_token) {
1058
+ removeOAuthState(this.config.clientId);
1059
+ removeOAuthNonce(this.config.clientId);
1060
+ removePkceVerifier(this.config.clientId);
1061
+ removeSilentProbe(this.config.clientId);
1062
+ removeTokenData(this.config.clientId);
1063
+ throw new SsoError(
1064
+ "id_token_invalid",
1065
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1066
+ );
1067
+ }
901
1068
  if (data.id_token) {
902
1069
  try {
903
1070
  await validateIdToken(
@@ -927,6 +1094,7 @@ var _SsoClient = class _SsoClient {
927
1094
  expires_at: now + data.expires_in * 1e3
928
1095
  };
929
1096
  saveTokenData(tokenData, this.config.clientId);
1097
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
930
1098
  return tokenData;
931
1099
  }
932
1100
  /**
@@ -962,7 +1130,7 @@ var _SsoClient = class _SsoClient {
962
1130
  let lastErr;
963
1131
  for (let attempt = 0; attempt < 2; attempt++) {
964
1132
  try {
965
- res = await fetch(tokenEndpoint, {
1133
+ res = await fetchWithTimeout(tokenEndpoint, {
966
1134
  method: "POST",
967
1135
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
968
1136
  body: body.toString()
@@ -984,15 +1152,20 @@ var _SsoClient = class _SsoClient {
984
1152
  } catch {
985
1153
  }
986
1154
  const errorCode = errData.error || "";
987
- if (errorCode === "invalid_grant" || res.status === 401) {
1155
+ if (errorCode === "invalid_grant") {
988
1156
  removeTokenData(this.config.clientId);
989
1157
  }
1158
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1159
+ if (!errorCode && res.status === 401) {
1160
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1161
+ }
990
1162
  throw new SsoError(
991
1163
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
992
1164
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
993
1165
  );
994
1166
  }
995
1167
  const data = await res.json();
1168
+ assertValidTokenResponse(data, false);
996
1169
  if (data.id_token) {
997
1170
  try {
998
1171
  await validateIdToken(
@@ -1030,13 +1203,13 @@ var _SsoClient = class _SsoClient {
1030
1203
  if (!tokenData) {
1031
1204
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1032
1205
  }
1033
- if (Date.now() >= tokenData.expires_at) {
1206
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1034
1207
  tokenData = await this.refreshToken();
1035
1208
  }
1036
1209
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1037
1210
  let res;
1038
1211
  try {
1039
- res = await fetch(userinfoEndpoint, {
1212
+ res = await fetchWithTimeout(userinfoEndpoint, {
1040
1213
  headers: {
1041
1214
  Authorization: `Bearer ${tokenData.access_token}`
1042
1215
  }
@@ -1065,7 +1238,7 @@ var _SsoClient = class _SsoClient {
1065
1238
  async getAccessToken() {
1066
1239
  let tokenData = getTokenData(this.config.clientId);
1067
1240
  if (!tokenData) return null;
1068
- if (Date.now() >= tokenData.expires_at) {
1241
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1069
1242
  tokenData = await this.refreshToken();
1070
1243
  }
1071
1244
  return tokenData.access_token;
@@ -1074,6 +1247,9 @@ var _SsoClient = class _SsoClient {
1074
1247
  * 检查是否已认证(不发起网络请求)
1075
1248
  *
1076
1249
  * 仅检查本地是否存在未过期的 access_token。
1250
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1251
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1252
+ * getAccessToken/getUserInfo 已带提前量。
1077
1253
  */
1078
1254
  isAuthenticated() {
1079
1255
  const tokenData = getTokenData(this.config.clientId);
@@ -1093,6 +1269,10 @@ var _SsoClient = class _SsoClient {
1093
1269
  const refreshToken = tokenData?.refresh_token;
1094
1270
  const idTokenHint = tokenData?.id_token;
1095
1271
  clearAllSsoData(this.config.clientId);
1272
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1273
+ clientId: this.config.clientId,
1274
+ redirectToSso
1275
+ });
1096
1276
  if (refreshToken && this.config.clientId) {
1097
1277
  try {
1098
1278
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1105,11 +1285,15 @@ var _SsoClient = class _SsoClient {
1105
1285
  if (this.config.clientSecret) {
1106
1286
  revokeBody.set("client_secret", this.config.clientSecret);
1107
1287
  }
1108
- await fetch(revokeUrl, {
1109
- method: "POST",
1110
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1111
- body: revokeBody.toString()
1112
- });
1288
+ await fetchWithTimeout(
1289
+ revokeUrl,
1290
+ {
1291
+ method: "POST",
1292
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1293
+ body: revokeBody.toString()
1294
+ },
1295
+ REVOKE_TIMEOUT_MS
1296
+ );
1113
1297
  } catch {
1114
1298
  }
1115
1299
  }
@@ -1179,6 +1363,21 @@ var SsoClient = _SsoClient;
1179
1363
  var SsoContext = createContext(null);
1180
1364
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1181
1365
  var LOCK_TTL_MS = 5e3;
1366
+ var CHANNEL_PREFIX = "nihplod_sso_events:";
1367
+ var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1368
+ function broadcastSsoEvent(clientId, type, tokenData) {
1369
+ if (typeof BroadcastChannel === "undefined") return;
1370
+ try {
1371
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1372
+ channel.postMessage({
1373
+ type,
1374
+ sourceTabId: TAB_ID,
1375
+ ...type === "token" && tokenData ? { tokenData } : {}
1376
+ });
1377
+ channel.close();
1378
+ } catch {
1379
+ }
1380
+ }
1182
1381
  var ownedLocks = /* @__PURE__ */ new Map();
1183
1382
  function lockKey(clientId) {
1184
1383
  return REFRESH_LOCK_PREFIX + clientId;
@@ -1237,21 +1436,37 @@ function SsoProvider({
1237
1436
  config,
1238
1437
  children,
1239
1438
  refreshThreshold = 60,
1240
- onTokenRefreshed
1439
+ onTokenRefreshed,
1440
+ onSessionExpired
1241
1441
  }) {
1242
1442
  const [user, setUser] = useState(null);
1243
1443
  const [isLoading, setIsLoading] = useState(true);
1244
1444
  const [isAuthenticated, setIsAuthenticated] = useState(false);
1245
1445
  const [error, setError] = useState(null);
1446
+ const [sessionExpired, setSessionExpired] = useState(false);
1246
1447
  const [client] = useState(() => new SsoClient(config));
1247
1448
  const refreshTimerRef = useRef(null);
1248
1449
  const loadedRef = useRef(false);
1450
+ const onSessionExpiredRef = useRef(onSessionExpired);
1451
+ useEffect(() => {
1452
+ onSessionExpiredRef.current = onSessionExpired;
1453
+ });
1454
+ const sessionExpiredNotifiedRef = useRef(false);
1455
+ const refreshFailureCountRef = useRef(0);
1456
+ const MAX_REFRESH_FAILURES = 3;
1457
+ const markSessionExpired = useCallback((error2) => {
1458
+ setError(error2);
1459
+ setSessionExpired(true);
1460
+ if (!sessionExpiredNotifiedRef.current) {
1461
+ sessionExpiredNotifiedRef.current = true;
1462
+ onSessionExpiredRef.current?.(error2);
1463
+ }
1464
+ }, []);
1249
1465
  const loadUser = useCallback(async () => {
1250
1466
  const tokenData = getTokenData(client.config.clientId);
1251
1467
  if (!tokenData) {
1252
1468
  setUser(null);
1253
1469
  setIsAuthenticated(false);
1254
- setError(null);
1255
1470
  setIsLoading(false);
1256
1471
  return;
1257
1472
  }
@@ -1260,19 +1475,22 @@ function SsoProvider({
1260
1475
  setUser(u);
1261
1476
  setIsAuthenticated(true);
1262
1477
  setError(null);
1478
+ setSessionExpired(false);
1479
+ sessionExpiredNotifiedRef.current = false;
1480
+ refreshFailureCountRef.current = 0;
1263
1481
  } catch (err) {
1264
1482
  setUser(null);
1265
1483
  setIsAuthenticated(false);
1266
- setError(
1267
- err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1268
- );
1269
- if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1484
+ const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
1485
+ setError(errorObj);
1486
+ if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
1270
1487
  removeTokenData(client.config.clientId);
1488
+ markSessionExpired(errorObj);
1271
1489
  }
1272
1490
  } finally {
1273
1491
  setIsLoading(false);
1274
1492
  }
1275
- }, [client]);
1493
+ }, [client, markSessionExpired]);
1276
1494
  useEffect(() => {
1277
1495
  if (loadedRef.current) return;
1278
1496
  loadedRef.current = true;
@@ -1289,13 +1507,28 @@ function SsoProvider({
1289
1507
  const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1290
1508
  try {
1291
1509
  const td = await client.refreshToken();
1510
+ refreshFailureCountRef.current = 0;
1292
1511
  onTokenRefreshed?.(td.access_token);
1512
+ broadcastSsoEvent(client.config.clientId, "token", td);
1293
1513
  loadUser();
1294
- } catch {
1514
+ } catch (err) {
1515
+ if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
1516
+ removeTokenData(client.config.clientId);
1517
+ markSessionExpired(err);
1518
+ } else {
1519
+ refreshFailureCountRef.current += 1;
1520
+ if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
1521
+ markSessionExpired(
1522
+ err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
1523
+ );
1524
+ }
1525
+ }
1295
1526
  setTimeout(() => loadUser(), 500);
1296
1527
  }
1528
+ }).finally(() => {
1529
+ if (active) scheduleNextRefresh(5e3);
1297
1530
  });
1298
- const scheduleNextRefresh = () => {
1531
+ const scheduleNextRefresh = (minDelayMs = 0) => {
1299
1532
  if (!active) return;
1300
1533
  if (refreshTimerRef.current) {
1301
1534
  clearTimeout(refreshTimerRef.current);
@@ -1304,26 +1537,27 @@ function SsoProvider({
1304
1537
  const tokenData = getTokenData(client.config.clientId);
1305
1538
  if (!tokenData) return;
1306
1539
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
1307
- if (remainingSec <= 0) {
1308
- void attemptRefresh();
1309
- return;
1310
- }
1311
- if (remainingSec <= refreshThreshold) {
1312
- void attemptRefresh();
1540
+ if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1541
+ refreshTimerRef.current = setTimeout(
1542
+ () => {
1543
+ if (active) void attemptRefresh();
1544
+ },
1545
+ Math.max(minDelayMs, 1e3)
1546
+ );
1313
1547
  return;
1314
1548
  }
1315
- const delayMs = (remainingSec - refreshThreshold) * 1e3;
1549
+ const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1316
1550
  refreshTimerRef.current = setTimeout(() => {
1317
1551
  if (!active) return;
1318
1552
  const td = getTokenData(client.config.clientId);
1319
1553
  if (!td) return;
1320
1554
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1321
1555
  if (secLeft <= refreshThreshold) {
1322
- void attemptRefresh().then((didRefresh) => {
1323
- if (didRefresh) scheduleNextRefresh();
1324
- });
1556
+ void attemptRefresh();
1557
+ } else {
1558
+ scheduleNextRefresh();
1325
1559
  }
1326
- }, Math.max(delayMs, 1e3));
1560
+ }, delayMs);
1327
1561
  };
1328
1562
  scheduleNextRefresh();
1329
1563
  return () => {
@@ -1343,6 +1577,30 @@ function SsoProvider({
1343
1577
  window.addEventListener("storage", handleStorageChange);
1344
1578
  return () => window.removeEventListener("storage", handleStorageChange);
1345
1579
  }, [loadUser]);
1580
+ useEffect(() => {
1581
+ if (typeof BroadcastChannel === "undefined") return;
1582
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
1583
+ channel.onmessage = (event) => {
1584
+ const data = event.data;
1585
+ if (!data || data.sourceTabId === TAB_ID) return;
1586
+ if (data.type === "logout") {
1587
+ removeTokenData(client.config.clientId);
1588
+ setUser(null);
1589
+ setIsAuthenticated(false);
1590
+ setError(null);
1591
+ setSessionExpired(false);
1592
+ sessionExpiredNotifiedRef.current = false;
1593
+ refreshFailureCountRef.current = 0;
1594
+ } else if (data.type === "token") {
1595
+ const td = data.tokenData;
1596
+ if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
1597
+ saveTokenData(td, client.config.clientId);
1598
+ }
1599
+ loadUser();
1600
+ }
1601
+ };
1602
+ return () => channel.close();
1603
+ }, [client, loadUser]);
1346
1604
  const login = useCallback(
1347
1605
  async (returnUrl) => {
1348
1606
  await client.login(returnUrl);
@@ -1359,10 +1617,17 @@ function SsoProvider({
1359
1617
  );
1360
1618
  const logout = useCallback(
1361
1619
  async (redirectToSso = false) => {
1362
- await client.logout(redirectToSso);
1363
1620
  setUser(null);
1364
1621
  setIsAuthenticated(false);
1365
1622
  setError(null);
1623
+ setSessionExpired(false);
1624
+ sessionExpiredNotifiedRef.current = false;
1625
+ refreshFailureCountRef.current = 0;
1626
+ broadcastSsoEvent(client.config.clientId, "logout");
1627
+ try {
1628
+ await client.logout(redirectToSso);
1629
+ } catch {
1630
+ }
1366
1631
  },
1367
1632
  [client]
1368
1633
  );
@@ -1382,6 +1647,7 @@ function SsoProvider({
1382
1647
  logout,
1383
1648
  refreshUser,
1384
1649
  getAccessToken,
1650
+ sessionExpired,
1385
1651
  client
1386
1652
  };
1387
1653
  return React.createElement(SsoContext.Provider, { value }, children);
@@ -1476,6 +1742,36 @@ function withAuth(Component) {
1476
1742
 
1477
1743
  // src/react/CallbackPage.tsx
1478
1744
  import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
1745
+ var CALLBACK_ERROR_HINTS = {
1746
+ state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1747
+ pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1748
+ user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
1749
+ session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1750
+ authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1751
+ authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1752
+ token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1753
+ userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1754
+ not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
1755
+ network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
1756
+ rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1757
+ popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
1758
+ popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1759
+ client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1760
+ account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
1761
+ sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1762
+ invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1763
+ no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1764
+ };
1765
+ function getCallbackErrorMessage(err) {
1766
+ if (err instanceof SsoError) {
1767
+ const hint = CALLBACK_ERROR_HINTS[err.code];
1768
+ if (hint) return hint;
1769
+ if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1770
+ if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
1771
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1772
+ }
1773
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
1774
+ }
1479
1775
  function DefaultCallbackError({ error }) {
1480
1776
  return React3.createElement(
1481
1777
  "div",
@@ -1486,18 +1782,54 @@ function DefaultCallbackError({ error }) {
1486
1782
  alignItems: "center",
1487
1783
  justifyContent: "center",
1488
1784
  minHeight: "100vh",
1489
- fontFamily: "system-ui, sans-serif"
1785
+ fontFamily: "system-ui, sans-serif",
1786
+ padding: "2rem",
1787
+ textAlign: "center"
1490
1788
  }
1491
1789
  },
1492
1790
  React3.createElement(
1493
1791
  "p",
1494
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1792
+ { style: { color: "#dc2626", marginBottom: "1.5rem" } },
1495
1793
  error
1496
1794
  ),
1497
1795
  React3.createElement(
1498
- "a",
1499
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1500
- "\u8FD4\u56DE\u9996\u9875"
1796
+ "div",
1797
+ { style: { display: "flex", gap: "0.75rem" } },
1798
+ React3.createElement(
1799
+ "a",
1800
+ {
1801
+ href: "/",
1802
+ style: {
1803
+ display: "inline-block",
1804
+ padding: "0.6rem 1.4rem",
1805
+ background: "#2c2c2c",
1806
+ color: "#fff",
1807
+ fontSize: "0.8125rem",
1808
+ textDecoration: "none"
1809
+ }
1810
+ },
1811
+ "\u91CD\u65B0\u767B\u5F55"
1812
+ ),
1813
+ React3.createElement(
1814
+ "button",
1815
+ {
1816
+ type: "button",
1817
+ onClick: () => {
1818
+ if (window.history.length > 1) window.history.back();
1819
+ else window.location.href = "/";
1820
+ },
1821
+ style: {
1822
+ display: "inline-block",
1823
+ padding: "0.6rem 1.4rem",
1824
+ border: "1px solid rgba(44, 44, 44, 0.25)",
1825
+ background: "transparent",
1826
+ color: "#2c2c2c",
1827
+ fontSize: "0.8125rem",
1828
+ cursor: "pointer"
1829
+ }
1830
+ },
1831
+ "\u8FD4\u56DE\u4E0A\u4E00\u9875"
1832
+ )
1501
1833
  )
1502
1834
  );
1503
1835
  }
@@ -1512,9 +1844,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1512
1844
  onSuccessRef.current = onSuccess;
1513
1845
  onErrorRef.current = onError;
1514
1846
  });
1847
+ const callbackPromiseRef = useRef3(null);
1848
+ const postProcessedRef = useRef3(false);
1849
+ const errorHandledRef = useRef3(false);
1515
1850
  useEffect3(() => {
1516
- if (window.opener && !window.opener.closed) {
1517
- const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1851
+ const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
1852
+ if (window.opener && !window.opener.closed && popupNonce) {
1853
+ const nonce = popupNonce;
1518
1854
  let targetOrigin = window.location.origin;
1519
1855
  try {
1520
1856
  targetOrigin = window.opener.location.origin;
@@ -1523,7 +1859,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1523
1859
  const message = {
1524
1860
  type: "nihplod_sso_popup_callback",
1525
1861
  callbackUrl: window.location.href,
1526
- nonce: nonce || void 0
1862
+ nonce
1527
1863
  };
1528
1864
  let acked = false;
1529
1865
  const send = () => {
@@ -1538,9 +1874,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1538
1874
  setAwaitingManualClose(true);
1539
1875
  }, 1e4);
1540
1876
  const handleAck = (event) => {
1877
+ if (event.source !== window.opener) return;
1541
1878
  if (event.origin !== targetOrigin) return;
1542
1879
  if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1543
- if (nonce && event.data.nonce !== nonce) return;
1880
+ if (event.data.nonce !== nonce) return;
1544
1881
  acked = true;
1545
1882
  clearInterval(resendTimer);
1546
1883
  clearTimeout(ackTimeout);
@@ -1556,8 +1893,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1556
1893
  let cancelled = false;
1557
1894
  async function handleCallback() {
1558
1895
  try {
1559
- const tokenData = await client.handleCallback(window.location.href);
1560
- if (cancelled) return;
1896
+ if (!callbackPromiseRef.current) {
1897
+ callbackPromiseRef.current = client.handleCallback(window.location.href);
1898
+ }
1899
+ const tokenData = await callbackPromiseRef.current;
1900
+ if (cancelled || postProcessedRef.current) return;
1901
+ postProcessedRef.current = true;
1561
1902
  if (tokenData === null) {
1562
1903
  const clientId2 = client.config.clientId;
1563
1904
  const probeReturnUrl = getReturnUrl(clientId2);
@@ -1578,14 +1919,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1578
1919
  }
1579
1920
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1580
1921
  } catch (err) {
1581
- if (cancelled) return;
1922
+ if (cancelled || errorHandledRef.current) return;
1923
+ errorHandledRef.current = true;
1924
+ callbackPromiseRef.current = null;
1582
1925
  const errorObj = err instanceof Error ? err : new Error(String(err));
1583
1926
  onErrorRef.current?.(errorObj);
1584
- if (errorObj instanceof SsoError) {
1585
- setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
1586
- } else {
1587
- setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
1588
- }
1927
+ setError(getCallbackErrorMessage(errorObj));
1589
1928
  setProcessing(false);
1590
1929
  }
1591
1930
  }