@nihplod/sso-sdk 1.0.1 → 1.2.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.
@@ -3,9 +3,15 @@ import React, { ReactNode, ComponentType } from 'react';
3
3
  /**
4
4
  * Token 存储抽象层
5
5
  *
6
- * 默认使用 localStorage,以支持多 Tab 间自动同步 token 并避免并发刷新。
7
- * 对 XSS 敏感的子项目可通过 setTokenStorage() 注入更安全的自定义实现
8
- *(如内存存储、Service Worker 封装、或带加密的 storage)。
6
+ * 分两类存储:
7
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
8
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
9
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
10
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
11
+ * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
12
+ * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
13
+ * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
14
+ * SSR 等无 sessionStorage 环境自动降级为内存 Map。
9
15
  *
10
16
  * 多 client 隔离:
11
17
  * - token / state / return_url 均支持按 clientId 隔离 key
@@ -54,6 +60,8 @@ interface SsoClientConfig {
54
60
  ssoBaseUrl: string;
55
61
  /** 请求的 scope(空格分隔),如 "openid profile phone" */
56
62
  scopes?: string;
63
+ /** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
64
+ postLogoutRedirectUri?: string;
57
65
  }
58
66
  /** 用户信息 */
59
67
  interface SsoUser {
@@ -102,26 +110,78 @@ declare class SsoClient {
102
110
  private _getTokenEndpoint;
103
111
  /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
104
112
  private _getUserinfoEndpoint;
113
+ /**
114
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
115
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
116
+ */
117
+ private _saveReturnUrlIfTrusted;
105
118
  /**
106
119
  * 发起 SSO 登录
107
120
  *
108
121
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
109
122
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
110
123
  *
111
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
124
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
125
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
126
+ *
127
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
128
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
129
+ * 同一次登录只使用其中一个入口。
112
130
  */
113
131
  login(returnUrl?: string): Promise<void>;
114
132
  /**
115
133
  * 构建登录 URL(不跳转,返回 URL 字符串)
116
134
  *
117
135
  * 适用于需要手动处理跳转的场景。
136
+ *
137
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
138
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
139
+ * 同一次登录只使用其中一个入口。
118
140
  */
119
141
  getLoginUrl(returnUrl?: string): Promise<string>;
142
+ /**
143
+ * 弹窗模式 SSO 登录
144
+ *
145
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
146
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
147
+ *
148
+ * 流程:
149
+ * 1. window.open() 打开授权 URL 到弹窗
150
+ * 2. 用户在弹窗中完成登录
151
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
152
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
153
+ * 5. 弹窗自动关闭
154
+ *
155
+ * @param options - 弹窗配置
156
+ * @param options.returnUrl - 登录成功后的返回地址
157
+ * @param options.width - 弹窗宽度(默认 480)
158
+ * @param options.height - 弹窗高度(默认 640)
159
+ * @returns TokenData
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
164
+ * try {
165
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
166
+ * console.log("登录成功", tokenData);
167
+ * } catch (err) {
168
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
169
+ * // 弹窗被拦截,回退到同页重定向
170
+ * await client.login("/dashboard");
171
+ * }
172
+ * }
173
+ * ```
174
+ */
175
+ loginPopup(options?: {
176
+ returnUrl?: string;
177
+ width?: number;
178
+ height?: number;
179
+ }): Promise<TokenData>;
120
180
  /**
121
181
  * 处理 OAuth 回调
122
182
  *
123
183
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
124
- * 成功后 token 自动保存到 sessionStorage。
184
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
125
185
  *
126
186
  * @param callbackUrl - 完整的回调 URL(window.location.href)
127
187
  * @returns TokenData 或 null
@@ -159,9 +219,28 @@ declare class SsoClient {
159
219
  * 登出
160
220
  *
161
221
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
162
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
222
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
223
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
224
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
163
225
  */
164
226
  logout(redirectToSso?: boolean): Promise<void>;
227
+ /**
228
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
229
+ *
230
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
231
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
232
+ * 校验后清除已保存的 logout state(一次性)。
233
+ *
234
+ * @param url - 当前页面完整 URL(window.location.href)
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * if (sso.validateLogoutState(window.location.href)) {
239
+ * // 来自 SSO 登出的可信回跳
240
+ * }
241
+ * ```
242
+ */
243
+ validateLogoutState(url: string): boolean;
165
244
  /**
166
245
  * 获取 OIDC Discovery 文档
167
246
  *
@@ -171,6 +250,21 @@ declare class SsoClient {
171
250
  getDiscovery(): Promise<OidcDiscovery | null>;
172
251
  }
173
252
 
253
+ /**
254
+ * SSO SDK 错误类型
255
+ */
256
+ /** SSO 错误码 */
257
+ 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";
258
+ /**
259
+ * SSO SDK 自定义错误
260
+ */
261
+ declare class SsoError extends Error {
262
+ readonly code: SsoErrorCode;
263
+ readonly description: string;
264
+ readonly cause?: unknown;
265
+ constructor(code: SsoErrorCode, description: string, cause?: unknown);
266
+ }
267
+
174
268
  /**
175
269
  * SsoProvider - React Context Provider
176
270
  *
@@ -180,6 +274,12 @@ declare class SsoClient {
180
274
  * - 提供 useSso() hook
181
275
  */
182
276
 
277
+ /**
278
+ * 认证状态为三态:
279
+ * - isLoading=true:初始化/刷新中,user 与 error 均可能为 null
280
+ * - error 非 null:加载用户信息失败(如会话已失效),user 为 null
281
+ * - isAuthenticated=true 且 user 非 null:已登录
282
+ */
183
283
  interface SsoContextValue {
184
284
  /** 当前用户信息 */
185
285
  user: SsoUser | null;
@@ -187,8 +287,16 @@ interface SsoContextValue {
187
287
  isAuthenticated: boolean;
188
288
  /** 是否正在加载(初始化/刷新中) */
189
289
  isLoading: boolean;
190
- /** 发起登录 */
290
+ /** 最近一次加载用户信息失败的错误(成功或登出后为 null) */
291
+ error: SsoError | null;
292
+ /** 发起登录(同页重定向) */
191
293
  login: (returnUrl?: string) => Promise<void>;
294
+ /** 弹窗模式登录(保持当前页面状态不丢失) */
295
+ loginPopup: (options?: {
296
+ returnUrl?: string;
297
+ width?: number;
298
+ height?: number;
299
+ }) => Promise<TokenData>;
192
300
  /** 登出 */
193
301
  logout: (redirectToSso?: boolean) => Promise<void>;
194
302
  /** 刷新用户信息 */
@@ -210,8 +318,9 @@ interface SsoProviderProps {
210
318
  */
211
319
  refreshThreshold?: number;
212
320
  /**
213
- * API 请求函数(可选)
214
- * 用于在 token 刷新后自动重试失败的 API 请求。
321
+ * Token 静默刷新成功后的回调(可选)。
322
+ * 每次刷新成功时以新的 access_token 调用,可用于同步 token 到外部状态;
323
+ * SDK 不会据此自动重试先前失败的 API 请求,重试需由调用方自行实现。
215
324
  */
216
325
  onTokenRefreshed?: (token: string) => void;
217
326
  }
@@ -247,20 +356,38 @@ interface RequireAuthProps {
247
356
  fallback?: React.ReactNode;
248
357
  /** 是否在检测到未登录时自动发起登录跳转 */
249
358
  autoLogin?: boolean;
359
+ /**
360
+ * 是否使用弹窗模式登录(保持当前页面状态不丢失)
361
+ *
362
+ * 仅在 autoLogin=true 时生效。弹窗被拦截时自动回退到同页重定向。
363
+ */
364
+ usePopup?: boolean;
365
+ /**
366
+ * 登录发起失败时的回调(如弹窗被关闭 popup_closed)。
367
+ * 失败后组件会展示重试入口,不会永久停在"请先登录"。
368
+ */
369
+ onError?: (error: unknown) => void;
370
+ /**
371
+ * 自定义登录失败 UI。不传时显示默认的错误提示与"重试"按钮。
372
+ * retry() 会重新发起登录。
373
+ */
374
+ renderLoginError?: (error: unknown, retry: () => void) => React.ReactNode;
250
375
  }
251
376
  /**
252
377
  * 路由保护组件
253
378
  *
254
379
  * 未登录时自动触发登录跳转或显示回退内容。
380
+ * 登录发起失败(如弹窗被关闭)时显示重试入口,并通过 onError 上报。
255
381
  *
256
382
  * @example
257
383
  * ```tsx
258
- * <RequireAuth>
384
+ * // 弹窗模式:不中断用户当前操作
385
+ * <RequireAuth autoLogin usePopup>
259
386
  * <Dashboard />
260
387
  * </RequireAuth>
261
388
  * ```
262
389
  */
263
- declare function RequireAuth({ children, fallback, autoLogin, }: 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>;
390
+ 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>;
264
391
  /**
265
392
  * 高阶组件:包装页面组件,要求认证后才能访问
266
393
  *
@@ -276,7 +403,8 @@ declare function withAuth<P extends object>(Component: ComponentType<P>): Compon
276
403
  * CallbackPage — 通用 OAuth 回调页面组件
277
404
  *
278
405
  * 子项目只需在回调路由渲染此组件即可完成 code → token 交换。
279
- * 交换成功后自动跳转到 returnUrl 或首页。
406
+ * 交换成功后默认整页跳转到 returnUrl 或首页;
407
+ * 传入 onSuccess 可跳过默认跳转,由 SPA 路由接管(保持应用状态)。
280
408
  *
281
409
  * @example
282
410
  * ```tsx
@@ -286,9 +414,42 @@ declare function withAuth<P extends object>(Component: ComponentType<P>): Compon
286
414
  * return <CallbackPage />;
287
415
  * }
288
416
  * ```
417
+ *
418
+ * @example SPA 路由接管跳转(不整页刷新):
419
+ * ```tsx
420
+ * <CallbackPage onSuccess={() => navigate("/dashboard", { replace: true })} />
421
+ * ```
289
422
  */
290
423
 
291
- declare function CallbackPage(): React.DetailedReactHTMLElement<{
424
+ interface CallbackPageProps {
425
+ /**
426
+ * 登录成功回调。传入后跳过默认的整页跳转(window.location.href),
427
+ * 由调用方用 SPA 路由接管跳转,避免丢失应用内状态。
428
+ */
429
+ onSuccess?: (tokenData: TokenData) => void;
430
+ /** 登录失败回调(错误同时会展示在错误页,除非提供了 renderError) */
431
+ onError?: (error: Error) => void;
432
+ /**
433
+ * 自定义错误页渲染。不传时使用默认错误 UI(DefaultCallbackError)。
434
+ */
435
+ renderError?: (error: string) => React.ReactNode;
436
+ }
437
+ /** 默认错误页 UI(可通过 renderError 完全替换) */
438
+ declare function DefaultCallbackError({ error }: {
439
+ error: string;
440
+ }): React.DetailedReactHTMLElement<{
441
+ style: {
442
+ display: "flex";
443
+ flexDirection: "column";
444
+ alignItems: "center";
445
+ justifyContent: "center";
446
+ minHeight: string;
447
+ fontFamily: "system-ui, sans-serif";
448
+ };
449
+ }, HTMLElement>;
450
+ declare function CallbackPage({ onSuccess, onError, renderError }?: CallbackPageProps): React.FunctionComponentElement<React.FragmentProps> | React.FunctionComponentElement<{
451
+ error: string;
452
+ }> | React.DetailedReactHTMLElement<{
292
453
  style: {
293
454
  display: "flex";
294
455
  alignItems: "center";
@@ -298,4 +459,4 @@ declare function CallbackPage(): React.DetailedReactHTMLElement<{
298
459
  };
299
460
  }, HTMLElement> | null;
300
461
 
301
- export { CallbackPage, RequireAuth, SsoProvider, useSso, withAuth };
462
+ export { CallbackPage, type CallbackPageProps, DefaultCallbackError, RequireAuth, SsoProvider, useSso, withAuth };