@nihplod/sso-sdk 1.1.0 → 1.2.1

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.
@@ -4,8 +4,10 @@ import React, { ReactNode, ComponentType } from 'react';
4
4
  * Token 存储抽象层
5
5
  *
6
6
  * 分两类存储:
7
- * - Token 数据:默认使用内存存储,Public Client 浏览器中 refresh_token 不落盘,
8
- * XSS 无法窃取长期凭证。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
7
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
8
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
9
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
10
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
9
11
  * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
10
12
  * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
11
13
  * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
@@ -66,7 +68,12 @@ interface SsoUser {
66
68
  sub: string;
67
69
  nickname?: string;
68
70
  avatar?: string;
71
+ /** 手机号(脱敏,兼容保留的非标准 claim,新代码请使用 phone_number) */
69
72
  phone?: string;
73
+ /** 手机号(脱敏,OIDC 标准 claim,与 phone 内容一致) */
74
+ phone_number?: string;
75
+ /** 生日(ISO 8601,需 birthday scope,未设置时为 null) */
76
+ birthday?: string | null;
70
77
  membership_level?: string;
71
78
  total_points?: number;
72
79
  }
@@ -121,12 +128,20 @@ declare class SsoClient {
121
128
  *
122
129
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
123
130
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
131
+ *
132
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
133
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
134
+ * 同一次登录只使用其中一个入口。
124
135
  */
125
136
  login(returnUrl?: string): Promise<void>;
126
137
  /**
127
138
  * 构建登录 URL(不跳转,返回 URL 字符串)
128
139
  *
129
140
  * 适用于需要手动处理跳转的场景。
141
+ *
142
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
143
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
144
+ * 同一次登录只使用其中一个入口。
130
145
  */
131
146
  getLoginUrl(returnUrl?: string): Promise<string>;
132
147
  /**
@@ -171,7 +186,7 @@ declare class SsoClient {
171
186
  * 处理 OAuth 回调
172
187
  *
173
188
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
174
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
189
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
175
190
  *
176
191
  * @param callbackUrl - 完整的回调 URL(window.location.href)
177
192
  * @returns TokenData 或 null
@@ -209,9 +224,28 @@ declare class SsoClient {
209
224
  * 登出
210
225
  *
211
226
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
212
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
227
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
228
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
229
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
213
230
  */
214
231
  logout(redirectToSso?: boolean): Promise<void>;
232
+ /**
233
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
234
+ *
235
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
236
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
237
+ * 校验后清除已保存的 logout state(一次性)。
238
+ *
239
+ * @param url - 当前页面完整 URL(window.location.href)
240
+ *
241
+ * @example
242
+ * ```typescript
243
+ * if (sso.validateLogoutState(window.location.href)) {
244
+ * // 来自 SSO 登出的可信回跳
245
+ * }
246
+ * ```
247
+ */
248
+ validateLogoutState(url: string): boolean;
215
249
  /**
216
250
  * 获取 OIDC Discovery 文档
217
251
  *
@@ -221,6 +255,21 @@ declare class SsoClient {
221
255
  getDiscovery(): Promise<OidcDiscovery | null>;
222
256
  }
223
257
 
258
+ /**
259
+ * SSO SDK 错误类型
260
+ */
261
+ /** SSO 错误码 */
262
+ type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
263
+ /**
264
+ * SSO SDK 自定义错误
265
+ */
266
+ declare class SsoError extends Error {
267
+ readonly code: SsoErrorCode;
268
+ readonly description: string;
269
+ readonly cause?: unknown;
270
+ constructor(code: SsoErrorCode, description: string, cause?: unknown);
271
+ }
272
+
224
273
  /**
225
274
  * SsoProvider - React Context Provider
226
275
  *
@@ -230,6 +279,12 @@ declare class SsoClient {
230
279
  * - 提供 useSso() hook
231
280
  */
232
281
 
282
+ /**
283
+ * 认证状态为三态:
284
+ * - isLoading=true:初始化/刷新中,user 与 error 均可能为 null
285
+ * - error 非 null:加载用户信息失败(如会话已失效),user 为 null
286
+ * - isAuthenticated=true 且 user 非 null:已登录
287
+ */
233
288
  interface SsoContextValue {
234
289
  /** 当前用户信息 */
235
290
  user: SsoUser | null;
@@ -237,6 +292,8 @@ interface SsoContextValue {
237
292
  isAuthenticated: boolean;
238
293
  /** 是否正在加载(初始化/刷新中) */
239
294
  isLoading: boolean;
295
+ /** 最近一次加载用户信息失败的错误(成功或登出后为 null) */
296
+ error: SsoError | null;
240
297
  /** 发起登录(同页重定向) */
241
298
  login: (returnUrl?: string) => Promise<void>;
242
299
  /** 弹窗模式登录(保持当前页面状态不丢失) */
@@ -266,8 +323,9 @@ interface SsoProviderProps {
266
323
  */
267
324
  refreshThreshold?: number;
268
325
  /**
269
- * API 请求函数(可选)
270
- * 用于在 token 刷新后自动重试失败的 API 请求。
326
+ * Token 静默刷新成功后的回调(可选)。
327
+ * 每次刷新成功时以新的 access_token 调用,可用于同步 token 到外部状态;
328
+ * SDK 不会据此自动重试先前失败的 API 请求,重试需由调用方自行实现。
271
329
  */
272
330
  onTokenRefreshed?: (token: string) => void;
273
331
  }
@@ -309,11 +367,22 @@ interface RequireAuthProps {
309
367
  * 仅在 autoLogin=true 时生效。弹窗被拦截时自动回退到同页重定向。
310
368
  */
311
369
  usePopup?: boolean;
370
+ /**
371
+ * 登录发起失败时的回调(如弹窗被关闭 popup_closed)。
372
+ * 失败后组件会展示重试入口,不会永久停在"请先登录"。
373
+ */
374
+ onError?: (error: unknown) => void;
375
+ /**
376
+ * 自定义登录失败 UI。不传时显示默认的错误提示与"重试"按钮。
377
+ * retry() 会重新发起登录。
378
+ */
379
+ renderLoginError?: (error: unknown, retry: () => void) => React.ReactNode;
312
380
  }
313
381
  /**
314
382
  * 路由保护组件
315
383
  *
316
384
  * 未登录时自动触发登录跳转或显示回退内容。
385
+ * 登录发起失败(如弹窗被关闭)时显示重试入口,并通过 onError 上报。
317
386
  *
318
387
  * @example
319
388
  * ```tsx
@@ -323,7 +392,7 @@ interface RequireAuthProps {
323
392
  * </RequireAuth>
324
393
  * ```
325
394
  */
326
- declare function RequireAuth({ children, fallback, autoLogin, usePopup, }: RequireAuthProps): string | number | bigint | true | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.FunctionComponentElement<React.FragmentProps>;
395
+ declare function RequireAuth({ children, fallback, autoLogin, usePopup, onError, renderLoginError, }: RequireAuthProps): string | number | bigint | true | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.FunctionComponentElement<React.FragmentProps>;
327
396
  /**
328
397
  * 高阶组件:包装页面组件,要求认证后才能访问
329
398
  *
@@ -339,7 +408,8 @@ declare function withAuth<P extends object>(Component: ComponentType<P>): Compon
339
408
  * CallbackPage — 通用 OAuth 回调页面组件
340
409
  *
341
410
  * 子项目只需在回调路由渲染此组件即可完成 code → token 交换。
342
- * 交换成功后自动跳转到 returnUrl 或首页。
411
+ * 交换成功后默认整页跳转到 returnUrl 或首页;
412
+ * 传入 onSuccess 可跳过默认跳转,由 SPA 路由接管(保持应用状态)。
343
413
  *
344
414
  * @example
345
415
  * ```tsx
@@ -349,9 +419,42 @@ declare function withAuth<P extends object>(Component: ComponentType<P>): Compon
349
419
  * return <CallbackPage />;
350
420
  * }
351
421
  * ```
422
+ *
423
+ * @example SPA 路由接管跳转(不整页刷新):
424
+ * ```tsx
425
+ * <CallbackPage onSuccess={() => navigate("/dashboard", { replace: true })} />
426
+ * ```
352
427
  */
353
428
 
354
- declare function CallbackPage(): React.DetailedReactHTMLElement<{
429
+ interface CallbackPageProps {
430
+ /**
431
+ * 登录成功回调。传入后跳过默认的整页跳转(window.location.href),
432
+ * 由调用方用 SPA 路由接管跳转,避免丢失应用内状态。
433
+ */
434
+ onSuccess?: (tokenData: TokenData) => void;
435
+ /** 登录失败回调(错误同时会展示在错误页,除非提供了 renderError) */
436
+ onError?: (error: Error) => void;
437
+ /**
438
+ * 自定义错误页渲染。不传时使用默认错误 UI(DefaultCallbackError)。
439
+ */
440
+ renderError?: (error: string) => React.ReactNode;
441
+ }
442
+ /** 默认错误页 UI(可通过 renderError 完全替换) */
443
+ declare function DefaultCallbackError({ error }: {
444
+ error: string;
445
+ }): React.DetailedReactHTMLElement<{
446
+ style: {
447
+ display: "flex";
448
+ flexDirection: "column";
449
+ alignItems: "center";
450
+ justifyContent: "center";
451
+ minHeight: string;
452
+ fontFamily: "system-ui, sans-serif";
453
+ };
454
+ }, HTMLElement>;
455
+ declare function CallbackPage({ onSuccess, onError, renderError }?: CallbackPageProps): React.FunctionComponentElement<React.FragmentProps> | React.FunctionComponentElement<{
456
+ error: string;
457
+ }> | React.DetailedReactHTMLElement<{
355
458
  style: {
356
459
  display: "flex";
357
460
  alignItems: "center";
@@ -361,4 +464,4 @@ declare function CallbackPage(): React.DetailedReactHTMLElement<{
361
464
  };
362
465
  }, HTMLElement> | null;
363
466
 
364
- export { CallbackPage, RequireAuth, SsoProvider, useSso, withAuth };
467
+ export { CallbackPage, type CallbackPageProps, DefaultCallbackError, RequireAuth, SsoProvider, useSso, withAuth };
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var react_exports = {};
32
32
  __export(react_exports, {
33
33
  CallbackPage: () => CallbackPage,
34
+ DefaultCallbackError: () => DefaultCallbackError,
34
35
  RequireAuth: () => RequireAuth,
35
36
  SsoProvider: () => SsoProvider,
36
37
  useSso: () => useSso,
@@ -117,6 +118,7 @@ var TOKEN_KEY = "token";
117
118
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
118
119
  var STATE_KEY = "oauth_state";
119
120
  var RETURN_URL_KEY = "return_url";
121
+ var LOGOUT_STATE_KEY = "logout_state";
120
122
  function buildKey(base, clientId) {
121
123
  return clientId ? `${base}:${clientId}` : base;
122
124
  }
@@ -163,7 +165,7 @@ function createSessionStorageAdapter() {
163
165
  };
164
166
  }
165
167
  var _transient = createSessionStorageAdapter();
166
- var _storage = memoryStorageAdapter;
168
+ var _storage = createSessionStorageAdapter();
167
169
  function saveTokenData(data, clientId) {
168
170
  _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
169
171
  }
@@ -197,6 +199,15 @@ function getOAuthState(clientId) {
197
199
  function removeOAuthState(clientId) {
198
200
  _transient.remove(buildKey(STATE_KEY, clientId));
199
201
  }
202
+ function saveLogoutState(state, clientId) {
203
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
204
+ }
205
+ function getLogoutState(clientId) {
206
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
207
+ }
208
+ function removeLogoutState(clientId) {
209
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
210
+ }
200
211
  function saveReturnUrl(url, clientId) {
201
212
  _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
202
213
  }
@@ -211,6 +222,7 @@ function clearAllSsoData(clientId) {
211
222
  removeTokenData(clientId);
212
223
  removeOAuthState(clientId);
213
224
  removeReturnUrl(clientId);
225
+ removeLogoutState(clientId);
214
226
  removePkceVerifier(clientId);
215
227
  removePkceVerifier(`${clientId}_popup_nonce`);
216
228
  return;
@@ -218,6 +230,7 @@ function clearAllSsoData(clientId) {
218
230
  removeTokenData();
219
231
  removeOAuthState();
220
232
  removeReturnUrl();
233
+ removeLogoutState();
221
234
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
222
235
  const stores = [
223
236
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -240,6 +253,7 @@ function clearAllSsoData(clientId) {
240
253
  // src/core/security.ts
241
254
  function isTrustedReturnUrl(url, currentOrigin) {
242
255
  if (!url) return false;
256
+ if (url.includes("\\")) return false;
243
257
  if (url.startsWith("/") && !url.startsWith("//")) return true;
244
258
  try {
245
259
  return new URL(url).origin === currentOrigin;
@@ -445,7 +459,7 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
445
459
  if (typeof payload.exp !== "number") {
446
460
  throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
447
461
  }
448
- if (Date.now() >= payload.exp * 1e3) {
462
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
449
463
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
450
464
  }
451
465
  if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
@@ -538,7 +552,7 @@ var _SsoClient = class _SsoClient {
538
552
  */
539
553
  _saveReturnUrlIfTrusted(returnUrl) {
540
554
  if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
541
- saveReturnUrl(returnUrl);
555
+ saveReturnUrl(returnUrl, this.config.clientId);
542
556
  } else {
543
557
  console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
544
558
  }
@@ -554,6 +568,10 @@ var _SsoClient = class _SsoClient {
554
568
  *
555
569
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
556
570
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
571
+ *
572
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
573
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
574
+ * 同一次登录只使用其中一个入口。
557
575
  */
558
576
  async login(returnUrl) {
559
577
  const verifier = generateCodeVerifier();
@@ -579,6 +597,10 @@ var _SsoClient = class _SsoClient {
579
597
  * 构建登录 URL(不跳转,返回 URL 字符串)
580
598
  *
581
599
  * 适用于需要手动处理跳转的场景。
600
+ *
601
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
602
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
603
+ * 同一次登录只使用其中一个入口。
582
604
  */
583
605
  async getLoginUrl(returnUrl) {
584
606
  const verifier = generateCodeVerifier();
@@ -731,7 +753,7 @@ var _SsoClient = class _SsoClient {
731
753
  * 处理 OAuth 回调
732
754
  *
733
755
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
734
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
756
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
735
757
  *
736
758
  * @param callbackUrl - 完整的回调 URL(window.location.href)
737
759
  * @returns TokenData 或 null
@@ -741,12 +763,16 @@ var _SsoClient = class _SsoClient {
741
763
  const params = url.searchParams;
742
764
  const error = params.get("error");
743
765
  if (error) {
766
+ removeOAuthState(this.config.clientId);
767
+ removePkceVerifier(this.config.clientId);
744
768
  const desc = params.get("error_description") || error;
745
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
769
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
746
770
  }
747
771
  const code = params.get("code");
748
772
  const returnedState = params.get("state");
749
773
  if (!code) {
774
+ removeOAuthState(this.config.clientId);
775
+ removePkceVerifier(this.config.clientId);
750
776
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
751
777
  }
752
778
  const savedState = getOAuthState(this.config.clientId);
@@ -798,8 +824,6 @@ var _SsoClient = class _SsoClient {
798
824
  );
799
825
  }
800
826
  const data = await res.json();
801
- removeOAuthState(this.config.clientId);
802
- removePkceVerifier(this.config.clientId);
803
827
  if (data.id_token) {
804
828
  try {
805
829
  await validateIdToken(
@@ -813,6 +837,8 @@ var _SsoClient = class _SsoClient {
813
837
  throw err;
814
838
  }
815
839
  }
840
+ removeOAuthState(this.config.clientId);
841
+ removePkceVerifier(this.config.clientId);
816
842
  const now = Date.now();
817
843
  const tokenData = {
818
844
  access_token: data.access_token,
@@ -981,7 +1007,9 @@ var _SsoClient = class _SsoClient {
981
1007
  * 登出
982
1008
  *
983
1009
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
984
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
1010
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
1011
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
1012
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
985
1013
  */
986
1014
  async logout(redirectToSso = false) {
987
1015
  const tokenData = getTokenData(this.config.clientId);
@@ -1024,10 +1052,36 @@ var _SsoClient = class _SsoClient {
1024
1052
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
1025
1053
  }
1026
1054
  const state = generateState();
1055
+ saveLogoutState(state, this.config.clientId);
1027
1056
  logoutUrl.searchParams.set("state", state);
1028
1057
  window.location.href = logoutUrl.toString();
1029
1058
  }
1030
1059
  }
1060
+ /**
1061
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1062
+ *
1063
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1064
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1065
+ * 校验后清除已保存的 logout state(一次性)。
1066
+ *
1067
+ * @param url - 当前页面完整 URL(window.location.href)
1068
+ *
1069
+ * @example
1070
+ * ```typescript
1071
+ * if (sso.validateLogoutState(window.location.href)) {
1072
+ * // 来自 SSO 登出的可信回跳
1073
+ * }
1074
+ * ```
1075
+ */
1076
+ validateLogoutState(url) {
1077
+ const returnedState = new URL(url).searchParams.get("state");
1078
+ if (!returnedState) return false;
1079
+ const savedState = getLogoutState(this.config.clientId);
1080
+ if (!savedState) return false;
1081
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1082
+ removeLogoutState(this.config.clientId);
1083
+ return true;
1084
+ }
1031
1085
  /**
1032
1086
  * 获取 OIDC Discovery 文档
1033
1087
  *
@@ -1111,6 +1165,7 @@ function SsoProvider({
1111
1165
  const [user, setUser] = (0, import_react.useState)(null);
1112
1166
  const [isLoading, setIsLoading] = (0, import_react.useState)(true);
1113
1167
  const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
1168
+ const [error, setError] = (0, import_react.useState)(null);
1114
1169
  const [client] = (0, import_react.useState)(() => new SsoClient(config));
1115
1170
  const refreshTimerRef = (0, import_react.useRef)(null);
1116
1171
  const loadedRef = (0, import_react.useRef)(false);
@@ -1119,6 +1174,7 @@ function SsoProvider({
1119
1174
  if (!tokenData) {
1120
1175
  setUser(null);
1121
1176
  setIsAuthenticated(false);
1177
+ setError(null);
1122
1178
  setIsLoading(false);
1123
1179
  return;
1124
1180
  }
@@ -1126,9 +1182,13 @@ function SsoProvider({
1126
1182
  const u = await client.getUserInfo();
1127
1183
  setUser(u);
1128
1184
  setIsAuthenticated(true);
1129
- } catch {
1185
+ setError(null);
1186
+ } catch (err) {
1130
1187
  setUser(null);
1131
1188
  setIsAuthenticated(false);
1189
+ setError(
1190
+ err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1191
+ );
1132
1192
  removeTokenData(client.config.clientId);
1133
1193
  } finally {
1134
1194
  setIsLoading(false);
@@ -1223,6 +1283,7 @@ function SsoProvider({
1223
1283
  await client.logout(redirectToSso);
1224
1284
  setUser(null);
1225
1285
  setIsAuthenticated(false);
1286
+ setError(null);
1226
1287
  },
1227
1288
  [client]
1228
1289
  );
@@ -1236,6 +1297,7 @@ function SsoProvider({
1236
1297
  user,
1237
1298
  isAuthenticated,
1238
1299
  isLoading,
1300
+ error,
1239
1301
  login,
1240
1302
  loginPopup,
1241
1303
  logout,
@@ -1259,29 +1321,62 @@ function RequireAuth({
1259
1321
  children,
1260
1322
  fallback,
1261
1323
  autoLogin = true,
1262
- usePopup = false
1324
+ usePopup = false,
1325
+ onError,
1326
+ renderLoginError
1263
1327
  }) {
1264
1328
  const { isAuthenticated, isLoading, login, loginPopup } = useSso();
1265
1329
  const loginTriggeredRef = (0, import_react2.useRef)(false);
1330
+ const [loginError, setLoginError] = (0, import_react2.useState)(null);
1331
+ const triggerLogin = () => {
1332
+ loginTriggeredRef.current = true;
1333
+ const currentPath = window.location.pathname + window.location.search;
1334
+ const handleFailure = (err) => {
1335
+ loginTriggeredRef.current = false;
1336
+ setLoginError(err);
1337
+ onError?.(err);
1338
+ };
1339
+ if (usePopup) {
1340
+ loginPopup({ returnUrl: currentPath }).catch((err) => {
1341
+ if (err.code === "popup_blocked") {
1342
+ login(currentPath).catch(handleFailure);
1343
+ return;
1344
+ }
1345
+ handleFailure(err);
1346
+ });
1347
+ } else {
1348
+ login(currentPath).catch(handleFailure);
1349
+ }
1350
+ };
1351
+ const retry = () => {
1352
+ setLoginError(null);
1353
+ triggerLogin();
1354
+ };
1266
1355
  (0, import_react2.useEffect)(() => {
1267
1356
  if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
1268
- loginTriggeredRef.current = true;
1269
- const currentPath = window.location.pathname + window.location.search;
1270
- if (usePopup) {
1271
- loginPopup({ returnUrl: currentPath }).catch((err) => {
1272
- if (err.code === "popup_blocked") {
1273
- login(currentPath);
1274
- }
1275
- });
1276
- } else {
1277
- login(currentPath);
1278
- }
1357
+ triggerLogin();
1279
1358
  }
1280
1359
  }, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
1281
1360
  if (isLoading) {
1282
1361
  return fallback || import_react2.default.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
1283
1362
  }
1284
1363
  if (!isAuthenticated) {
1364
+ if (loginError) {
1365
+ if (renderLoginError) {
1366
+ return import_react2.default.createElement(
1367
+ import_react2.default.Fragment,
1368
+ null,
1369
+ renderLoginError(loginError, retry)
1370
+ );
1371
+ }
1372
+ const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1373
+ return import_react2.default.createElement(
1374
+ "div",
1375
+ null,
1376
+ import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1377
+ import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1378
+ );
1379
+ }
1285
1380
  if (fallback) return import_react2.default.createElement(import_react2.default.Fragment, null, fallback);
1286
1381
  return import_react2.default.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
1287
1382
  }
@@ -1302,10 +1397,41 @@ function withAuth(Component) {
1302
1397
 
1303
1398
  // src/react/CallbackPage.tsx
1304
1399
  var import_react3 = __toESM(require("react"));
1305
- function CallbackPage() {
1400
+ function DefaultCallbackError({ error }) {
1401
+ return import_react3.default.createElement(
1402
+ "div",
1403
+ {
1404
+ style: {
1405
+ display: "flex",
1406
+ flexDirection: "column",
1407
+ alignItems: "center",
1408
+ justifyContent: "center",
1409
+ minHeight: "100vh",
1410
+ fontFamily: "system-ui, sans-serif"
1411
+ }
1412
+ },
1413
+ import_react3.default.createElement(
1414
+ "p",
1415
+ { style: { color: "#dc2626", marginBottom: "1rem" } },
1416
+ error
1417
+ ),
1418
+ import_react3.default.createElement(
1419
+ "a",
1420
+ { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1421
+ "\u8FD4\u56DE\u9996\u9875"
1422
+ )
1423
+ );
1424
+ }
1425
+ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1306
1426
  const { client, refreshUser } = useSso();
1307
1427
  const [error, setError] = (0, import_react3.useState)(null);
1308
1428
  const [processing, setProcessing] = (0, import_react3.useState)(true);
1429
+ const onSuccessRef = (0, import_react3.useRef)(onSuccess);
1430
+ const onErrorRef = (0, import_react3.useRef)(onError);
1431
+ (0, import_react3.useEffect)(() => {
1432
+ onSuccessRef.current = onSuccess;
1433
+ onErrorRef.current = onError;
1434
+ });
1309
1435
  (0, import_react3.useEffect)(() => {
1310
1436
  if (window.opener && !window.opener.closed) {
1311
1437
  const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
@@ -1324,22 +1450,27 @@ function CallbackPage() {
1324
1450
  let cancelled = false;
1325
1451
  async function handleCallback() {
1326
1452
  try {
1327
- await client.handleCallback(window.location.href);
1453
+ const tokenData = await client.handleCallback(window.location.href);
1328
1454
  if (cancelled) return;
1329
1455
  await refreshUser();
1330
- const returnUrl = getReturnUrl();
1331
- removeReturnUrl();
1456
+ if (cancelled) return;
1457
+ const clientId = client.config.clientId;
1458
+ const returnUrl = getReturnUrl(clientId);
1459
+ removeReturnUrl(clientId);
1460
+ const onSuccessCb = onSuccessRef.current;
1461
+ if (onSuccessCb) {
1462
+ onSuccessCb(tokenData);
1463
+ return;
1464
+ }
1332
1465
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1333
1466
  } catch (err) {
1334
1467
  if (cancelled) return;
1335
- if (err instanceof SsoError) {
1336
- setError(err.description || `SSO \u9519\u8BEF (${err.code})`);
1337
- } else if (err instanceof TypeError && err.message.includes("fetch")) {
1338
- setError("\u7F51\u7EDC\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5");
1339
- } else if (err instanceof Error) {
1340
- setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
1468
+ const errorObj = err instanceof Error ? err : new Error(String(err));
1469
+ onErrorRef.current?.(errorObj);
1470
+ if (errorObj instanceof SsoError) {
1471
+ setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
1341
1472
  } else {
1342
- setError("\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
1473
+ setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
1343
1474
  }
1344
1475
  setProcessing(false);
1345
1476
  }
@@ -1350,29 +1481,8 @@ function CallbackPage() {
1350
1481
  };
1351
1482
  }, [client, refreshUser]);
1352
1483
  if (error) {
1353
- return import_react3.default.createElement(
1354
- "div",
1355
- {
1356
- style: {
1357
- display: "flex",
1358
- flexDirection: "column",
1359
- alignItems: "center",
1360
- justifyContent: "center",
1361
- minHeight: "100vh",
1362
- fontFamily: "system-ui, sans-serif"
1363
- }
1364
- },
1365
- import_react3.default.createElement(
1366
- "p",
1367
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1368
- error
1369
- ),
1370
- import_react3.default.createElement(
1371
- "a",
1372
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1373
- "\u8FD4\u56DE\u9996\u9875"
1374
- )
1375
- );
1484
+ if (renderError) return import_react3.default.createElement(import_react3.default.Fragment, null, renderError(error));
1485
+ return import_react3.default.createElement(DefaultCallbackError, { error });
1376
1486
  }
1377
1487
  if (processing) {
1378
1488
  return import_react3.default.createElement(
@@ -1394,6 +1504,7 @@ function CallbackPage() {
1394
1504
  // Annotate the CommonJS export names for ESM import in node:
1395
1505
  0 && (module.exports = {
1396
1506
  CallbackPage,
1507
+ DefaultCallbackError,
1397
1508
  RequireAuth,
1398
1509
  SsoProvider,
1399
1510
  useSso,