@nihplod/sso-sdk 1.4.1 → 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.
@@ -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
  }
@@ -514,6 +561,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
514
561
  }
515
562
 
516
563
  // src/core/SsoClient.ts
564
+ var REQUEST_TIMEOUT_MS = 1e4;
565
+ var REVOKE_TIMEOUT_MS = 3e3;
566
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
567
+ const controller = new AbortController();
568
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
569
+ try {
570
+ return await fetch(input, { ...init, signal: controller.signal });
571
+ } finally {
572
+ clearTimeout(timer);
573
+ }
574
+ }
575
+ function assertValidTokenResponse(data, requireRefreshToken) {
576
+ if (typeof data.access_token !== "string" || !data.access_token) {
577
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
578
+ }
579
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
580
+ throw new SsoError(
581
+ "token_request_failed",
582
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
583
+ );
584
+ }
585
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
586
+ throw new SsoError(
587
+ "token_request_failed",
588
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
589
+ );
590
+ }
591
+ }
517
592
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
518
593
  "login_required",
519
594
  "consent_required",
@@ -532,6 +607,19 @@ var _SsoClient = class _SsoClient {
532
607
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
533
608
  }
534
609
  // ============================================
610
+ // 调试日志
611
+ // ============================================
612
+ /**
613
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
614
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
615
+ * 不会输出 token/授权码等敏感值。
616
+ */
617
+ debugLog(...args) {
618
+ if (this.config.debug) {
619
+ console.warn("[SSO SDK]", ...args);
620
+ }
621
+ }
622
+ // ============================================
535
623
  // 内部方法
536
624
  // ============================================
537
625
  /**
@@ -911,7 +999,7 @@ var _SsoClient = class _SsoClient {
911
999
  }
912
1000
  let res;
913
1001
  try {
914
- res = await fetch(tokenEndpoint, {
1002
+ res = await fetchWithTimeout(tokenEndpoint, {
915
1003
  method: "POST",
916
1004
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
917
1005
  body: body.toString()
@@ -932,6 +1020,15 @@ var _SsoClient = class _SsoClient {
932
1020
  );
933
1021
  }
934
1022
  const data = await res.json();
1023
+ assertValidTokenResponse(data, true);
1024
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1025
+ if (requestedScopes.includes("openid") && !data.id_token) {
1026
+ removeTokenData(this.config.clientId);
1027
+ throw new SsoError(
1028
+ "id_token_invalid",
1029
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1030
+ );
1031
+ }
935
1032
  if (data.id_token) {
936
1033
  try {
937
1034
  await validateIdToken(
@@ -961,6 +1058,7 @@ var _SsoClient = class _SsoClient {
961
1058
  expires_at: now + data.expires_in * 1e3
962
1059
  };
963
1060
  saveTokenData(tokenData, this.config.clientId);
1061
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
964
1062
  return tokenData;
965
1063
  }
966
1064
  /**
@@ -996,7 +1094,7 @@ var _SsoClient = class _SsoClient {
996
1094
  let lastErr;
997
1095
  for (let attempt = 0; attempt < 2; attempt++) {
998
1096
  try {
999
- res = await fetch(tokenEndpoint, {
1097
+ res = await fetchWithTimeout(tokenEndpoint, {
1000
1098
  method: "POST",
1001
1099
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1002
1100
  body: body.toString()
@@ -1018,15 +1116,20 @@ var _SsoClient = class _SsoClient {
1018
1116
  } catch {
1019
1117
  }
1020
1118
  const errorCode = errData.error || "";
1021
- if (errorCode === "invalid_grant" || res.status === 401) {
1119
+ if (errorCode === "invalid_grant") {
1022
1120
  removeTokenData(this.config.clientId);
1023
1121
  }
1122
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1123
+ if (!errorCode && res.status === 401) {
1124
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1125
+ }
1024
1126
  throw new SsoError(
1025
1127
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
1026
1128
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
1027
1129
  );
1028
1130
  }
1029
1131
  const data = await res.json();
1132
+ assertValidTokenResponse(data, false);
1030
1133
  if (data.id_token) {
1031
1134
  try {
1032
1135
  await validateIdToken(
@@ -1070,7 +1173,7 @@ var _SsoClient = class _SsoClient {
1070
1173
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1071
1174
  let res;
1072
1175
  try {
1073
- res = await fetch(userinfoEndpoint, {
1176
+ res = await fetchWithTimeout(userinfoEndpoint, {
1074
1177
  headers: {
1075
1178
  Authorization: `Bearer ${tokenData.access_token}`
1076
1179
  }
@@ -1127,6 +1230,10 @@ var _SsoClient = class _SsoClient {
1127
1230
  const refreshToken = tokenData?.refresh_token;
1128
1231
  const idTokenHint = tokenData?.id_token;
1129
1232
  clearAllSsoData(this.config.clientId);
1233
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1234
+ clientId: this.config.clientId,
1235
+ redirectToSso
1236
+ });
1130
1237
  if (refreshToken && this.config.clientId) {
1131
1238
  try {
1132
1239
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1139,11 +1246,15 @@ var _SsoClient = class _SsoClient {
1139
1246
  if (this.config.clientSecret) {
1140
1247
  revokeBody.set("client_secret", this.config.clientSecret);
1141
1248
  }
1142
- await fetch(revokeUrl, {
1143
- method: "POST",
1144
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1145
- body: revokeBody.toString()
1146
- });
1249
+ await fetchWithTimeout(
1250
+ revokeUrl,
1251
+ {
1252
+ method: "POST",
1253
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1254
+ body: revokeBody.toString()
1255
+ },
1256
+ REVOKE_TIMEOUT_MS
1257
+ );
1147
1258
  } catch {
1148
1259
  }
1149
1260
  }
@@ -1213,6 +1324,17 @@ var SsoClient = _SsoClient;
1213
1324
  var SsoContext = (0, import_react.createContext)(null);
1214
1325
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
1215
1326
  var LOCK_TTL_MS = 5e3;
1327
+ var CHANNEL_PREFIX = "nihplod_sso_events:";
1328
+ var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1329
+ function broadcastSsoEvent(clientId, type) {
1330
+ if (typeof BroadcastChannel === "undefined") return;
1331
+ try {
1332
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
1333
+ channel.postMessage({ type, sourceTabId: TAB_ID });
1334
+ channel.close();
1335
+ } catch {
1336
+ }
1337
+ }
1216
1338
  var ownedLocks = /* @__PURE__ */ new Map();
1217
1339
  function lockKey(clientId) {
1218
1340
  return REFRESH_LOCK_PREFIX + clientId;
@@ -1271,21 +1393,37 @@ function SsoProvider({
1271
1393
  config,
1272
1394
  children,
1273
1395
  refreshThreshold = 60,
1274
- onTokenRefreshed
1396
+ onTokenRefreshed,
1397
+ onSessionExpired
1275
1398
  }) {
1276
1399
  const [user, setUser] = (0, import_react.useState)(null);
1277
1400
  const [isLoading, setIsLoading] = (0, import_react.useState)(true);
1278
1401
  const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
1279
1402
  const [error, setError] = (0, import_react.useState)(null);
1403
+ const [sessionExpired, setSessionExpired] = (0, import_react.useState)(false);
1280
1404
  const [client] = (0, import_react.useState)(() => new SsoClient(config));
1281
1405
  const refreshTimerRef = (0, import_react.useRef)(null);
1282
1406
  const loadedRef = (0, import_react.useRef)(false);
1407
+ const onSessionExpiredRef = (0, import_react.useRef)(onSessionExpired);
1408
+ (0, import_react.useEffect)(() => {
1409
+ onSessionExpiredRef.current = onSessionExpired;
1410
+ });
1411
+ const sessionExpiredNotifiedRef = (0, import_react.useRef)(false);
1412
+ const refreshFailureCountRef = (0, import_react.useRef)(0);
1413
+ const MAX_REFRESH_FAILURES = 3;
1414
+ const markSessionExpired = (0, import_react.useCallback)((error2) => {
1415
+ setError(error2);
1416
+ setSessionExpired(true);
1417
+ if (!sessionExpiredNotifiedRef.current) {
1418
+ sessionExpiredNotifiedRef.current = true;
1419
+ onSessionExpiredRef.current?.(error2);
1420
+ }
1421
+ }, []);
1283
1422
  const loadUser = (0, import_react.useCallback)(async () => {
1284
1423
  const tokenData = getTokenData(client.config.clientId);
1285
1424
  if (!tokenData) {
1286
1425
  setUser(null);
1287
1426
  setIsAuthenticated(false);
1288
- setError(null);
1289
1427
  setIsLoading(false);
1290
1428
  return;
1291
1429
  }
@@ -1294,19 +1432,22 @@ function SsoProvider({
1294
1432
  setUser(u);
1295
1433
  setIsAuthenticated(true);
1296
1434
  setError(null);
1435
+ setSessionExpired(false);
1436
+ sessionExpiredNotifiedRef.current = false;
1437
+ refreshFailureCountRef.current = 0;
1297
1438
  } catch (err) {
1298
1439
  setUser(null);
1299
1440
  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")) {
1441
+ const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
1442
+ setError(errorObj);
1443
+ if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
1304
1444
  removeTokenData(client.config.clientId);
1445
+ markSessionExpired(errorObj);
1305
1446
  }
1306
1447
  } finally {
1307
1448
  setIsLoading(false);
1308
1449
  }
1309
- }, [client]);
1450
+ }, [client, markSessionExpired]);
1310
1451
  (0, import_react.useEffect)(() => {
1311
1452
  if (loadedRef.current) return;
1312
1453
  loadedRef.current = true;
@@ -1323,13 +1464,28 @@ function SsoProvider({
1323
1464
  const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1324
1465
  try {
1325
1466
  const td = await client.refreshToken();
1467
+ refreshFailureCountRef.current = 0;
1326
1468
  onTokenRefreshed?.(td.access_token);
1469
+ broadcastSsoEvent(client.config.clientId, "token");
1327
1470
  loadUser();
1328
- } catch {
1471
+ } catch (err) {
1472
+ if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
1473
+ removeTokenData(client.config.clientId);
1474
+ markSessionExpired(err);
1475
+ } else {
1476
+ refreshFailureCountRef.current += 1;
1477
+ if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
1478
+ markSessionExpired(
1479
+ err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
1480
+ );
1481
+ }
1482
+ }
1329
1483
  setTimeout(() => loadUser(), 500);
1330
1484
  }
1485
+ }).finally(() => {
1486
+ if (active) scheduleNextRefresh(5e3);
1331
1487
  });
1332
- const scheduleNextRefresh = () => {
1488
+ const scheduleNextRefresh = (minDelayMs = 0) => {
1333
1489
  if (!active) return;
1334
1490
  if (refreshTimerRef.current) {
1335
1491
  clearTimeout(refreshTimerRef.current);
@@ -1338,26 +1494,27 @@ function SsoProvider({
1338
1494
  const tokenData = getTokenData(client.config.clientId);
1339
1495
  if (!tokenData) return;
1340
1496
  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();
1497
+ if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
1498
+ refreshTimerRef.current = setTimeout(
1499
+ () => {
1500
+ if (active) void attemptRefresh();
1501
+ },
1502
+ Math.max(minDelayMs, 1e3)
1503
+ );
1347
1504
  return;
1348
1505
  }
1349
- const delayMs = (remainingSec - refreshThreshold) * 1e3;
1506
+ const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
1350
1507
  refreshTimerRef.current = setTimeout(() => {
1351
1508
  if (!active) return;
1352
1509
  const td = getTokenData(client.config.clientId);
1353
1510
  if (!td) return;
1354
1511
  const secLeft = (td.expires_at - Date.now()) / 1e3;
1355
1512
  if (secLeft <= refreshThreshold) {
1356
- void attemptRefresh().then((didRefresh) => {
1357
- if (didRefresh) scheduleNextRefresh();
1358
- });
1513
+ void attemptRefresh();
1514
+ } else {
1515
+ scheduleNextRefresh();
1359
1516
  }
1360
- }, Math.max(delayMs, 1e3));
1517
+ }, delayMs);
1361
1518
  };
1362
1519
  scheduleNextRefresh();
1363
1520
  return () => {
@@ -1377,6 +1534,26 @@ function SsoProvider({
1377
1534
  window.addEventListener("storage", handleStorageChange);
1378
1535
  return () => window.removeEventListener("storage", handleStorageChange);
1379
1536
  }, [loadUser]);
1537
+ (0, import_react.useEffect)(() => {
1538
+ if (typeof BroadcastChannel === "undefined") return;
1539
+ const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
1540
+ channel.onmessage = (event) => {
1541
+ const data = event.data;
1542
+ if (!data || data.sourceTabId === TAB_ID) return;
1543
+ if (data.type === "logout") {
1544
+ removeTokenData(client.config.clientId);
1545
+ setUser(null);
1546
+ setIsAuthenticated(false);
1547
+ setError(null);
1548
+ setSessionExpired(false);
1549
+ sessionExpiredNotifiedRef.current = false;
1550
+ refreshFailureCountRef.current = 0;
1551
+ } else if (data.type === "token") {
1552
+ loadUser();
1553
+ }
1554
+ };
1555
+ return () => channel.close();
1556
+ }, [client, loadUser]);
1380
1557
  const login = (0, import_react.useCallback)(
1381
1558
  async (returnUrl) => {
1382
1559
  await client.login(returnUrl);
@@ -1393,10 +1570,17 @@ function SsoProvider({
1393
1570
  );
1394
1571
  const logout = (0, import_react.useCallback)(
1395
1572
  async (redirectToSso = false) => {
1396
- await client.logout(redirectToSso);
1397
1573
  setUser(null);
1398
1574
  setIsAuthenticated(false);
1399
1575
  setError(null);
1576
+ setSessionExpired(false);
1577
+ sessionExpiredNotifiedRef.current = false;
1578
+ refreshFailureCountRef.current = 0;
1579
+ broadcastSsoEvent(client.config.clientId, "logout");
1580
+ try {
1581
+ await client.logout(redirectToSso);
1582
+ } catch {
1583
+ }
1400
1584
  },
1401
1585
  [client]
1402
1586
  );
@@ -1416,6 +1600,7 @@ function SsoProvider({
1416
1600
  logout,
1417
1601
  refreshUser,
1418
1602
  getAccessToken,
1603
+ sessionExpired,
1419
1604
  client
1420
1605
  };
1421
1606
  return import_react.default.createElement(SsoContext.Provider, { value }, children);
@@ -1510,6 +1695,36 @@ function withAuth(Component) {
1510
1695
 
1511
1696
  // src/react/CallbackPage.tsx
1512
1697
  var import_react3 = __toESM(require("react"));
1698
+ var CALLBACK_ERROR_HINTS = {
1699
+ state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1700
+ pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1701
+ user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
1702
+ session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1703
+ authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1704
+ authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1705
+ token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1706
+ userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1707
+ not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
1708
+ network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
1709
+ rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1710
+ popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
1711
+ popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
1712
+ client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1713
+ account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
1714
+ sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
1715
+ invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
1716
+ no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1717
+ };
1718
+ function getCallbackErrorMessage(err) {
1719
+ if (err instanceof SsoError) {
1720
+ const hint = CALLBACK_ERROR_HINTS[err.code];
1721
+ if (hint) return hint;
1722
+ if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1723
+ if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
1724
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
1725
+ }
1726
+ return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
1727
+ }
1513
1728
  function DefaultCallbackError({ error }) {
1514
1729
  return import_react3.default.createElement(
1515
1730
  "div",
@@ -1520,18 +1735,54 @@ function DefaultCallbackError({ error }) {
1520
1735
  alignItems: "center",
1521
1736
  justifyContent: "center",
1522
1737
  minHeight: "100vh",
1523
- fontFamily: "system-ui, sans-serif"
1738
+ fontFamily: "system-ui, sans-serif",
1739
+ padding: "2rem",
1740
+ textAlign: "center"
1524
1741
  }
1525
1742
  },
1526
1743
  import_react3.default.createElement(
1527
1744
  "p",
1528
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1745
+ { style: { color: "#dc2626", marginBottom: "1.5rem" } },
1529
1746
  error
1530
1747
  ),
1531
1748
  import_react3.default.createElement(
1532
- "a",
1533
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1534
- "\u8FD4\u56DE\u9996\u9875"
1749
+ "div",
1750
+ { style: { display: "flex", gap: "0.75rem" } },
1751
+ import_react3.default.createElement(
1752
+ "a",
1753
+ {
1754
+ href: "/",
1755
+ style: {
1756
+ display: "inline-block",
1757
+ padding: "0.6rem 1.4rem",
1758
+ background: "#2c2c2c",
1759
+ color: "#fff",
1760
+ fontSize: "0.8125rem",
1761
+ textDecoration: "none"
1762
+ }
1763
+ },
1764
+ "\u91CD\u65B0\u767B\u5F55"
1765
+ ),
1766
+ import_react3.default.createElement(
1767
+ "button",
1768
+ {
1769
+ type: "button",
1770
+ onClick: () => {
1771
+ if (window.history.length > 1) window.history.back();
1772
+ else window.location.href = "/";
1773
+ },
1774
+ style: {
1775
+ display: "inline-block",
1776
+ padding: "0.6rem 1.4rem",
1777
+ border: "1px solid rgba(44, 44, 44, 0.25)",
1778
+ background: "transparent",
1779
+ color: "#2c2c2c",
1780
+ fontSize: "0.8125rem",
1781
+ cursor: "pointer"
1782
+ }
1783
+ },
1784
+ "\u8FD4\u56DE\u4E0A\u4E00\u9875"
1785
+ )
1535
1786
  )
1536
1787
  );
1537
1788
  }
@@ -1546,9 +1797,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1546
1797
  onSuccessRef.current = onSuccess;
1547
1798
  onErrorRef.current = onError;
1548
1799
  });
1800
+ const callbackPromiseRef = (0, import_react3.useRef)(null);
1801
+ const postProcessedRef = (0, import_react3.useRef)(false);
1802
+ const errorHandledRef = (0, import_react3.useRef)(false);
1549
1803
  (0, import_react3.useEffect)(() => {
1550
- if (window.opener && !window.opener.closed) {
1551
- const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1804
+ const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
1805
+ if (window.opener && !window.opener.closed && popupNonce) {
1806
+ const nonce = popupNonce;
1552
1807
  let targetOrigin = window.location.origin;
1553
1808
  try {
1554
1809
  targetOrigin = window.opener.location.origin;
@@ -1557,7 +1812,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1557
1812
  const message = {
1558
1813
  type: "nihplod_sso_popup_callback",
1559
1814
  callbackUrl: window.location.href,
1560
- nonce: nonce || void 0
1815
+ nonce
1561
1816
  };
1562
1817
  let acked = false;
1563
1818
  const send = () => {
@@ -1572,9 +1827,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1572
1827
  setAwaitingManualClose(true);
1573
1828
  }, 1e4);
1574
1829
  const handleAck = (event) => {
1830
+ if (event.source !== window.opener) return;
1575
1831
  if (event.origin !== targetOrigin) return;
1576
1832
  if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1577
- if (nonce && event.data.nonce !== nonce) return;
1833
+ if (event.data.nonce !== nonce) return;
1578
1834
  acked = true;
1579
1835
  clearInterval(resendTimer);
1580
1836
  clearTimeout(ackTimeout);
@@ -1590,8 +1846,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1590
1846
  let cancelled = false;
1591
1847
  async function handleCallback() {
1592
1848
  try {
1593
- const tokenData = await client.handleCallback(window.location.href);
1594
- if (cancelled) return;
1849
+ if (!callbackPromiseRef.current) {
1850
+ callbackPromiseRef.current = client.handleCallback(window.location.href);
1851
+ }
1852
+ const tokenData = await callbackPromiseRef.current;
1853
+ if (cancelled || postProcessedRef.current) return;
1854
+ postProcessedRef.current = true;
1595
1855
  if (tokenData === null) {
1596
1856
  const clientId2 = client.config.clientId;
1597
1857
  const probeReturnUrl = getReturnUrl(clientId2);
@@ -1612,14 +1872,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1612
1872
  }
1613
1873
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1614
1874
  } catch (err) {
1615
- if (cancelled) return;
1875
+ if (cancelled || errorHandledRef.current) return;
1876
+ errorHandledRef.current = true;
1877
+ callbackPromiseRef.current = null;
1616
1878
  const errorObj = err instanceof Error ? err : new Error(String(err));
1617
1879
  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
- }
1880
+ setError(getCallbackErrorMessage(errorObj));
1623
1881
  setProcessing(false);
1624
1882
  }
1625
1883
  }