@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.
- package/README.md +73 -6
- package/dist/index.d.mts +51 -10
- package/dist/index.d.ts +51 -10
- package/dist/index.js +68 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +65 -9
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +38 -5
- package/dist/next/index.d.ts +38 -5
- package/dist/next/index.js +90 -49
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +88 -48
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +113 -10
- package/dist/react/index.d.ts +113 -10
- package/dist/react/index.js +166 -55
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +169 -59
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.d.ts
CHANGED
|
@@ -4,8 +4,10 @@ import React, { ReactNode, ComponentType } from 'react';
|
|
|
4
4
|
* Token 存储抽象层
|
|
5
5
|
*
|
|
6
6
|
* 分两类存储:
|
|
7
|
-
* - Token
|
|
8
|
-
*
|
|
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
|
|
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
|
-
*
|
|
270
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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 };
|
package/dist/react/index.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
setError(
|
|
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(
|
|
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
|
-
|
|
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,
|