@nihplod/sso-sdk 1.4.0 → 1.5.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
  }
@@ -480,6 +527,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
480
527
  }
481
528
 
482
529
  // src/core/SsoClient.ts
530
+ var REQUEST_TIMEOUT_MS = 1e4;
531
+ var REVOKE_TIMEOUT_MS = 3e3;
532
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
533
+ const controller = new AbortController();
534
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
535
+ try {
536
+ return await fetch(input, { ...init, signal: controller.signal });
537
+ } finally {
538
+ clearTimeout(timer);
539
+ }
540
+ }
541
+ function assertValidTokenResponse(data, requireRefreshToken) {
542
+ if (typeof data.access_token !== "string" || !data.access_token) {
543
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
544
+ }
545
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
546
+ throw new SsoError(
547
+ "token_request_failed",
548
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
549
+ );
550
+ }
551
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
552
+ throw new SsoError(
553
+ "token_request_failed",
554
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
555
+ );
556
+ }
557
+ }
483
558
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
484
559
  "login_required",
485
560
  "consent_required",
@@ -498,6 +573,19 @@ var _SsoClient = class _SsoClient {
498
573
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
499
574
  }
500
575
  // ============================================
576
+ // 调试日志
577
+ // ============================================
578
+ /**
579
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
580
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
581
+ * 不会输出 token/授权码等敏感值。
582
+ */
583
+ debugLog(...args) {
584
+ if (this.config.debug) {
585
+ console.warn("[SSO SDK]", ...args);
586
+ }
587
+ }
588
+ // ============================================
501
589
  // 内部方法
502
590
  // ============================================
503
591
  /**
@@ -877,7 +965,7 @@ var _SsoClient = class _SsoClient {
877
965
  }
878
966
  let res;
879
967
  try {
880
- res = await fetch(tokenEndpoint, {
968
+ res = await fetchWithTimeout(tokenEndpoint, {
881
969
  method: "POST",
882
970
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
883
971
  body: body.toString()
@@ -898,6 +986,15 @@ var _SsoClient = class _SsoClient {
898
986
  );
899
987
  }
900
988
  const data = await res.json();
989
+ assertValidTokenResponse(data, true);
990
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
991
+ if (requestedScopes.includes("openid") && !data.id_token) {
992
+ 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
+ );
997
+ }
901
998
  if (data.id_token) {
902
999
  try {
903
1000
  await validateIdToken(
@@ -927,6 +1024,7 @@ var _SsoClient = class _SsoClient {
927
1024
  expires_at: now + data.expires_in * 1e3
928
1025
  };
929
1026
  saveTokenData(tokenData, this.config.clientId);
1027
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
930
1028
  return tokenData;
931
1029
  }
932
1030
  /**
@@ -962,7 +1060,7 @@ var _SsoClient = class _SsoClient {
962
1060
  let lastErr;
963
1061
  for (let attempt = 0; attempt < 2; attempt++) {
964
1062
  try {
965
- res = await fetch(tokenEndpoint, {
1063
+ res = await fetchWithTimeout(tokenEndpoint, {
966
1064
  method: "POST",
967
1065
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
968
1066
  body: body.toString()
@@ -984,15 +1082,20 @@ var _SsoClient = class _SsoClient {
984
1082
  } catch {
985
1083
  }
986
1084
  const errorCode = errData.error || "";
987
- if (errorCode === "invalid_grant" || res.status === 401) {
1085
+ if (errorCode === "invalid_grant") {
988
1086
  removeTokenData(this.config.clientId);
989
1087
  }
1088
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1089
+ if (!errorCode && res.status === 401) {
1090
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1091
+ }
990
1092
  throw new SsoError(
991
1093
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
992
1094
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
993
1095
  );
994
1096
  }
995
1097
  const data = await res.json();
1098
+ assertValidTokenResponse(data, false);
996
1099
  if (data.id_token) {
997
1100
  try {
998
1101
  await validateIdToken(
@@ -1036,7 +1139,7 @@ var _SsoClient = class _SsoClient {
1036
1139
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1037
1140
  let res;
1038
1141
  try {
1039
- res = await fetch(userinfoEndpoint, {
1142
+ res = await fetchWithTimeout(userinfoEndpoint, {
1040
1143
  headers: {
1041
1144
  Authorization: `Bearer ${tokenData.access_token}`
1042
1145
  }
@@ -1093,6 +1196,10 @@ var _SsoClient = class _SsoClient {
1093
1196
  const refreshToken = tokenData?.refresh_token;
1094
1197
  const idTokenHint = tokenData?.id_token;
1095
1198
  clearAllSsoData(this.config.clientId);
1199
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1200
+ clientId: this.config.clientId,
1201
+ redirectToSso
1202
+ });
1096
1203
  if (refreshToken && this.config.clientId) {
1097
1204
  try {
1098
1205
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1105,11 +1212,15 @@ var _SsoClient = class _SsoClient {
1105
1212
  if (this.config.clientSecret) {
1106
1213
  revokeBody.set("client_secret", this.config.clientSecret);
1107
1214
  }
1108
- await fetch(revokeUrl, {
1109
- method: "POST",
1110
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1111
- body: revokeBody.toString()
1112
- });
1215
+ await fetchWithTimeout(
1216
+ revokeUrl,
1217
+ {
1218
+ method: "POST",
1219
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1220
+ body: revokeBody.toString()
1221
+ },
1222
+ REVOKE_TIMEOUT_MS
1223
+ );
1113
1224
  } catch {
1114
1225
  }
1115
1226
  }
@@ -1179,6 +1290,17 @@ var SsoClient = _SsoClient;
1179
1290
  var SsoContext = createContext(null);
1180
1291
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1181
1292
  var LOCK_TTL_MS = 5e3;
1293
+ var CHANNEL_PREFIX = "nihplod_sso_events:";
1294
+ var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1295
+ function broadcastSsoEvent(clientId, type) {
1296
+ if (typeof BroadcastChannel === "undefined") return;
1297
+ try {
1298
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1299
+ channel.postMessage({ type, sourceTabId: TAB_ID });
1300
+ channel.close();
1301
+ } catch {
1302
+ }
1303
+ }
1182
1304
  var ownedLocks = /* @__PURE__ */ new Map();
1183
1305
  function lockKey(clientId) {
1184
1306
  return REFRESH_LOCK_PREFIX + clientId;
@@ -1237,21 +1359,37 @@ function SsoProvider({
1237
1359
  config,
1238
1360
  children,
1239
1361
  refreshThreshold = 60,
1240
- onTokenRefreshed
1362
+ onTokenRefreshed,
1363
+ onSessionExpired
1241
1364
  }) {
1242
1365
  const [user, setUser] = useState(null);
1243
1366
  const [isLoading, setIsLoading] = useState(true);
1244
1367
  const [isAuthenticated, setIsAuthenticated] = useState(false);
1245
1368
  const [error, setError] = useState(null);
1369
+ const [sessionExpired, setSessionExpired] = useState(false);
1246
1370
  const [client] = useState(() => new SsoClient(config));
1247
1371
  const refreshTimerRef = useRef(null);
1248
1372
  const loadedRef = useRef(false);
1373
+ const onSessionExpiredRef = useRef(onSessionExpired);
1374
+ useEffect(() => {
1375
+ onSessionExpiredRef.current = onSessionExpired;
1376
+ });
1377
+ const sessionExpiredNotifiedRef = useRef(false);
1378
+ const refreshFailureCountRef = useRef(0);
1379
+ const MAX_REFRESH_FAILURES = 3;
1380
+ const markSessionExpired = useCallback((error2) => {
1381
+ setError(error2);
1382
+ setSessionExpired(true);
1383
+ if (!sessionExpiredNotifiedRef.current) {
1384
+ sessionExpiredNotifiedRef.current = true;
1385
+ onSessionExpiredRef.current?.(error2);
1386
+ }
1387
+ }, []);
1249
1388
  const loadUser = useCallback(async () => {
1250
1389
  const tokenData = getTokenData(client.config.clientId);
1251
1390
  if (!tokenData) {
1252
1391
  setUser(null);
1253
1392
  setIsAuthenticated(false);
1254
- setError(null);
1255
1393
  setIsLoading(false);
1256
1394
  return;
1257
1395
  }
@@ -1260,19 +1398,22 @@ function SsoProvider({
1260
1398
  setUser(u);
1261
1399
  setIsAuthenticated(true);
1262
1400
  setError(null);
1401
+ setSessionExpired(false);
1402
+ sessionExpiredNotifiedRef.current = false;
1403
+ refreshFailureCountRef.current = 0;
1263
1404
  } catch (err) {
1264
1405
  setUser(null);
1265
1406
  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")) {
1407
+ const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
1408
+ setError(errorObj);
1409
+ if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
1270
1410
  removeTokenData(client.config.clientId);
1411
+ markSessionExpired(errorObj);
1271
1412
  }
1272
1413
  } finally {
1273
1414
  setIsLoading(false);
1274
1415
  }
1275
- }, [client]);
1416
+ }, [client, markSessionExpired]);
1276
1417
  useEffect(() => {
1277
1418
  if (loadedRef.current) return;
1278
1419
  loadedRef.current = true;
@@ -1289,13 +1430,28 @@ function SsoProvider({
1289
1430
  const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1290
1431
  try {
1291
1432
  const td = await client.refreshToken();
1433
+ refreshFailureCountRef.current = 0;
1292
1434
  onTokenRefreshed?.(td.access_token);
1435
+ broadcastSsoEvent(client.config.clientId, "token");
1293
1436
  loadUser();
1294
- } catch {
1437
+ } catch (err) {
1438
+ if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
1439
+ removeTokenData(client.config.clientId);
1440
+ markSessionExpired(err);
1441
+ } else {
1442
+ refreshFailureCountRef.current += 1;
1443
+ if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
1444
+ markSessionExpired(
1445
+ err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
1446
+ );
1447
+ }
1448
+ }
1295
1449
  setTimeout(() => loadUser(), 500);
1296
1450
  }
1451
+ }).finally(() => {
1452
+ if (active) scheduleNextRefresh(5e3);
1297
1453
  });
1298
- const scheduleNextRefresh = () => {
1454
+ const scheduleNextRefresh = (minDelayMs = 0) => {
1299
1455
  if (!active) return;
1300
1456
  if (refreshTimerRef.current) {
1301
1457
  clearTimeout(refreshTimerRef.current);
@@ -1304,26 +1460,27 @@ function SsoProvider({
1304
1460
  const tokenData = getTokenData(client.config.clientId);
1305
1461
  if (!tokenData) return;
1306
1462
  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();
1463
+ if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1464
+ refreshTimerRef.current = setTimeout(
1465
+ () => {
1466
+ if (active) void attemptRefresh();
1467
+ },
1468
+ Math.max(minDelayMs, 1e3)
1469
+ );
1313
1470
  return;
1314
1471
  }
1315
- const delayMs = (remainingSec - refreshThreshold) * 1e3;
1472
+ const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1316
1473
  refreshTimerRef.current = setTimeout(() => {
1317
1474
  if (!active) return;
1318
1475
  const td = getTokenData(client.config.clientId);
1319
1476
  if (!td) return;
1320
1477
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1321
1478
  if (secLeft <= refreshThreshold) {
1322
- void attemptRefresh().then((didRefresh) => {
1323
- if (didRefresh) scheduleNextRefresh();
1324
- });
1479
+ void attemptRefresh();
1480
+ } else {
1481
+ scheduleNextRefresh();
1325
1482
  }
1326
- }, Math.max(delayMs, 1e3));
1483
+ }, delayMs);
1327
1484
  };
1328
1485
  scheduleNextRefresh();
1329
1486
  return () => {
@@ -1343,6 +1500,26 @@ function SsoProvider({
1343
1500
  window.addEventListener("storage", handleStorageChange);
1344
1501
  return () => window.removeEventListener("storage", handleStorageChange);
1345
1502
  }, [loadUser]);
1503
+ useEffect(() => {
1504
+ if (typeof BroadcastChannel === "undefined") return;
1505
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
1506
+ channel.onmessage = (event) => {
1507
+ const data = event.data;
1508
+ if (!data || data.sourceTabId === TAB_ID) return;
1509
+ if (data.type === "logout") {
1510
+ removeTokenData(client.config.clientId);
1511
+ setUser(null);
1512
+ setIsAuthenticated(false);
1513
+ setError(null);
1514
+ setSessionExpired(false);
1515
+ sessionExpiredNotifiedRef.current = false;
1516
+ refreshFailureCountRef.current = 0;
1517
+ } else if (data.type === "token") {
1518
+ loadUser();
1519
+ }
1520
+ };
1521
+ return () => channel.close();
1522
+ }, [client, loadUser]);
1346
1523
  const login = useCallback(
1347
1524
  async (returnUrl) => {
1348
1525
  await client.login(returnUrl);
@@ -1359,10 +1536,17 @@ function SsoProvider({
1359
1536
  );
1360
1537
  const logout = useCallback(
1361
1538
  async (redirectToSso = false) => {
1362
- await client.logout(redirectToSso);
1363
1539
  setUser(null);
1364
1540
  setIsAuthenticated(false);
1365
1541
  setError(null);
1542
+ setSessionExpired(false);
1543
+ sessionExpiredNotifiedRef.current = false;
1544
+ refreshFailureCountRef.current = 0;
1545
+ broadcastSsoEvent(client.config.clientId, "logout");
1546
+ try {
1547
+ await client.logout(redirectToSso);
1548
+ } catch {
1549
+ }
1366
1550
  },
1367
1551
  [client]
1368
1552
  );
@@ -1382,6 +1566,7 @@ function SsoProvider({
1382
1566
  logout,
1383
1567
  refreshUser,
1384
1568
  getAccessToken,
1569
+ sessionExpired,
1385
1570
  client
1386
1571
  };
1387
1572
  return React.createElement(SsoContext.Provider, { value }, children);
@@ -1476,6 +1661,36 @@ function withAuth(Component) {
1476
1661
 
1477
1662
  // src/react/CallbackPage.tsx
1478
1663
  import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
1664
+ var CALLBACK_ERROR_HINTS = {
1665
+ state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1666
+ pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1667
+ user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
1668
+ session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1669
+ authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1670
+ authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1671
+ token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1672
+ userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1673
+ not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
1674
+ network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
1675
+ rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1676
+ popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
1677
+ popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1678
+ client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1679
+ account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
1680
+ sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1681
+ invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1682
+ no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1683
+ };
1684
+ function getCallbackErrorMessage(err) {
1685
+ if (err instanceof SsoError) {
1686
+ const hint = CALLBACK_ERROR_HINTS[err.code];
1687
+ if (hint) return hint;
1688
+ if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1689
+ if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
1690
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1691
+ }
1692
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
1693
+ }
1479
1694
  function DefaultCallbackError({ error }) {
1480
1695
  return React3.createElement(
1481
1696
  "div",
@@ -1486,18 +1701,54 @@ function DefaultCallbackError({ error }) {
1486
1701
  alignItems: "center",
1487
1702
  justifyContent: "center",
1488
1703
  minHeight: "100vh",
1489
- fontFamily: "system-ui, sans-serif"
1704
+ fontFamily: "system-ui, sans-serif",
1705
+ padding: "2rem",
1706
+ textAlign: "center"
1490
1707
  }
1491
1708
  },
1492
1709
  React3.createElement(
1493
1710
  "p",
1494
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1711
+ { style: { color: "#dc2626", marginBottom: "1.5rem" } },
1495
1712
  error
1496
1713
  ),
1497
1714
  React3.createElement(
1498
- "a",
1499
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1500
- "\u8FD4\u56DE\u9996\u9875"
1715
+ "div",
1716
+ { style: { display: "flex", gap: "0.75rem" } },
1717
+ React3.createElement(
1718
+ "a",
1719
+ {
1720
+ href: "/",
1721
+ style: {
1722
+ display: "inline-block",
1723
+ padding: "0.6rem 1.4rem",
1724
+ background: "#2c2c2c",
1725
+ color: "#fff",
1726
+ fontSize: "0.8125rem",
1727
+ textDecoration: "none"
1728
+ }
1729
+ },
1730
+ "\u91CD\u65B0\u767B\u5F55"
1731
+ ),
1732
+ React3.createElement(
1733
+ "button",
1734
+ {
1735
+ type: "button",
1736
+ onClick: () => {
1737
+ if (window.history.length > 1) window.history.back();
1738
+ else window.location.href = "/";
1739
+ },
1740
+ style: {
1741
+ display: "inline-block",
1742
+ padding: "0.6rem 1.4rem",
1743
+ border: "1px solid rgba(44, 44, 44, 0.25)",
1744
+ background: "transparent",
1745
+ color: "#2c2c2c",
1746
+ fontSize: "0.8125rem",
1747
+ cursor: "pointer"
1748
+ }
1749
+ },
1750
+ "\u8FD4\u56DE\u4E0A\u4E00\u9875"
1751
+ )
1501
1752
  )
1502
1753
  );
1503
1754
  }
@@ -1512,9 +1763,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1512
1763
  onSuccessRef.current = onSuccess;
1513
1764
  onErrorRef.current = onError;
1514
1765
  });
1766
+ const callbackPromiseRef = useRef3(null);
1767
+ const postProcessedRef = useRef3(false);
1768
+ const errorHandledRef = useRef3(false);
1515
1769
  useEffect3(() => {
1516
- if (window.opener && !window.opener.closed) {
1517
- const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1770
+ const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
1771
+ if (window.opener && !window.opener.closed && popupNonce) {
1772
+ const nonce = popupNonce;
1518
1773
  let targetOrigin = window.location.origin;
1519
1774
  try {
1520
1775
  targetOrigin = window.opener.location.origin;
@@ -1523,7 +1778,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1523
1778
  const message = {
1524
1779
  type: "nihplod_sso_popup_callback",
1525
1780
  callbackUrl: window.location.href,
1526
- nonce: nonce || void 0
1781
+ nonce
1527
1782
  };
1528
1783
  let acked = false;
1529
1784
  const send = () => {
@@ -1538,9 +1793,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1538
1793
  setAwaitingManualClose(true);
1539
1794
  }, 1e4);
1540
1795
  const handleAck = (event) => {
1796
+ if (event.source !== window.opener) return;
1541
1797
  if (event.origin !== targetOrigin) return;
1542
1798
  if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1543
- if (nonce && event.data.nonce !== nonce) return;
1799
+ if (event.data.nonce !== nonce) return;
1544
1800
  acked = true;
1545
1801
  clearInterval(resendTimer);
1546
1802
  clearTimeout(ackTimeout);
@@ -1556,8 +1812,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1556
1812
  let cancelled = false;
1557
1813
  async function handleCallback() {
1558
1814
  try {
1559
- const tokenData = await client.handleCallback(window.location.href);
1560
- if (cancelled) return;
1815
+ if (!callbackPromiseRef.current) {
1816
+ callbackPromiseRef.current = client.handleCallback(window.location.href);
1817
+ }
1818
+ const tokenData = await callbackPromiseRef.current;
1819
+ if (cancelled || postProcessedRef.current) return;
1820
+ postProcessedRef.current = true;
1561
1821
  if (tokenData === null) {
1562
1822
  const clientId2 = client.config.clientId;
1563
1823
  const probeReturnUrl = getReturnUrl(clientId2);
@@ -1578,14 +1838,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1578
1838
  }
1579
1839
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1580
1840
  } catch (err) {
1581
- if (cancelled) return;
1841
+ if (cancelled || errorHandledRef.current) return;
1842
+ errorHandledRef.current = true;
1843
+ callbackPromiseRef.current = null;
1582
1844
  const errorObj = err instanceof Error ? err : new Error(String(err));
1583
1845
  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
- }
1846
+ setError(getCallbackErrorMessage(errorObj));
1589
1847
  setProcessing(false);
1590
1848
  }
1591
1849
  }