@nihplod/sso-sdk 1.3.0 → 1.4.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.
- package/README.md +52 -9
- package/dist/index.d.mts +26 -6
- package/dist/index.d.ts +26 -6
- package/dist/index.js +118 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -10
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +81 -4
- package/dist/next/index.d.ts +81 -4
- package/dist/next/index.js +287 -23
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +285 -23
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +26 -6
- package/dist/react/index.d.ts +26 -6
- package/dist/react/index.js +176 -16
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +176 -16
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import React, { ReactNode, ComponentType } from 'react';
|
|
|
9
9
|
* 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
|
|
10
10
|
* 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
|
|
11
11
|
* 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
|
|
12
|
-
* - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
|
|
12
|
+
* - 临时数据(PKCE verifier / state / OIDC nonce / returnUrl / popup nonce):必须跨整页重定向存活
|
|
13
13
|
* (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
|
|
14
14
|
* SSR 等无 sessionStorage 环境自动降级为内存 Map。
|
|
15
15
|
*
|
|
@@ -86,6 +86,21 @@ interface SsoUser {
|
|
|
86
86
|
total_spent?: number;
|
|
87
87
|
total_points?: number;
|
|
88
88
|
}
|
|
89
|
+
/** login() / getLoginUrl() 选项 */
|
|
90
|
+
interface LoginOptions {
|
|
91
|
+
/**
|
|
92
|
+
* OIDC prompt 参数:
|
|
93
|
+
* - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
|
|
94
|
+
* 无会话时 IdP 回跳 error=login_required(或 consent_required /
|
|
95
|
+
* interaction_required),handleCallback() 识别后返回 null 而不抛错
|
|
96
|
+
* (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
|
|
97
|
+
* ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
|
|
98
|
+
* 应由用户点击等明确动作触发。
|
|
99
|
+
* - "login":强制重新认证;
|
|
100
|
+
* - "consent":强制重新授权确认。
|
|
101
|
+
*/
|
|
102
|
+
prompt?: "none" | "login" | "consent";
|
|
103
|
+
}
|
|
89
104
|
/** OIDC Discovery 文档 */
|
|
90
105
|
interface OidcDiscovery {
|
|
91
106
|
issuer: string;
|
|
@@ -139,12 +154,15 @@ declare class SsoClient {
|
|
|
139
154
|
*
|
|
140
155
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
141
156
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
157
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
158
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
159
|
+
* handleCallback() 返回 null 而不抛错。
|
|
142
160
|
*
|
|
143
161
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
144
162
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
145
163
|
* 同一次登录只使用其中一个入口。
|
|
146
164
|
*/
|
|
147
|
-
login(returnUrl?: string): Promise<void>;
|
|
165
|
+
login(returnUrl?: string, options?: LoginOptions): Promise<void>;
|
|
148
166
|
/**
|
|
149
167
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
150
168
|
*
|
|
@@ -154,7 +172,7 @@ declare class SsoClient {
|
|
|
154
172
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
155
173
|
* 同一次登录只使用其中一个入口。
|
|
156
174
|
*/
|
|
157
|
-
getLoginUrl(returnUrl?: string): Promise<string>;
|
|
175
|
+
getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
|
|
158
176
|
/**
|
|
159
177
|
* 弹窗模式 SSO 登录
|
|
160
178
|
*
|
|
@@ -200,9 +218,11 @@ declare class SsoClient {
|
|
|
200
218
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
201
219
|
*
|
|
202
220
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
203
|
-
* @returns TokenData
|
|
221
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
222
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
223
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
204
224
|
*/
|
|
205
|
-
handleCallback(callbackUrl: string): Promise<TokenData>;
|
|
225
|
+
handleCallback(callbackUrl: string): Promise<TokenData | null>;
|
|
206
226
|
/**
|
|
207
227
|
* 刷新 Access Token
|
|
208
228
|
*
|
|
@@ -270,7 +290,7 @@ declare class SsoClient {
|
|
|
270
290
|
* SSO SDK 错误类型
|
|
271
291
|
*/
|
|
272
292
|
/** SSO 错误码 */
|
|
273
|
-
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";
|
|
293
|
+
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" | "id_token_nonce_mismatch" | "logout_token_invalid" | "logout_token_unsupported_alg" | "logout_token_invalid_signature" | "logout_token_issuer_mismatch" | "logout_token_audience_mismatch" | "logout_token_expired" | "logout_token_replay";
|
|
274
294
|
/**
|
|
275
295
|
* SSO SDK 自定义错误
|
|
276
296
|
*/
|
package/dist/react/index.js
CHANGED
|
@@ -117,8 +117,10 @@ var STORAGE_PREFIX = "nihplod_sso_";
|
|
|
117
117
|
var TOKEN_KEY = "token";
|
|
118
118
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
119
119
|
var STATE_KEY = "oauth_state";
|
|
120
|
+
var NONCE_KEY = "oidc_nonce";
|
|
120
121
|
var RETURN_URL_KEY = "return_url";
|
|
121
122
|
var LOGOUT_STATE_KEY = "logout_state";
|
|
123
|
+
var SILENT_PROBE_KEY = "silent_probe";
|
|
122
124
|
function buildKey(base, clientId) {
|
|
123
125
|
return clientId ? `${base}:${clientId}` : base;
|
|
124
126
|
}
|
|
@@ -199,6 +201,15 @@ function getOAuthState(clientId) {
|
|
|
199
201
|
function removeOAuthState(clientId) {
|
|
200
202
|
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
201
203
|
}
|
|
204
|
+
function saveOAuthNonce(nonce, clientId) {
|
|
205
|
+
_transient.set(buildKey(NONCE_KEY, clientId), nonce);
|
|
206
|
+
}
|
|
207
|
+
function getOAuthNonce(clientId) {
|
|
208
|
+
return _transient.get(buildKey(NONCE_KEY, clientId));
|
|
209
|
+
}
|
|
210
|
+
function removeOAuthNonce(clientId) {
|
|
211
|
+
_transient.remove(buildKey(NONCE_KEY, clientId));
|
|
212
|
+
}
|
|
202
213
|
function saveLogoutState(state, clientId) {
|
|
203
214
|
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
204
215
|
}
|
|
@@ -217,20 +228,33 @@ function getReturnUrl(clientId) {
|
|
|
217
228
|
function removeReturnUrl(clientId) {
|
|
218
229
|
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
219
230
|
}
|
|
231
|
+
function saveSilentProbe(state, clientId) {
|
|
232
|
+
_transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
|
|
233
|
+
}
|
|
234
|
+
function getSilentProbe(clientId) {
|
|
235
|
+
return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
|
|
236
|
+
}
|
|
237
|
+
function removeSilentProbe(clientId) {
|
|
238
|
+
_transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
|
|
239
|
+
}
|
|
220
240
|
function clearAllSsoData(clientId) {
|
|
221
241
|
if (clientId) {
|
|
222
242
|
removeTokenData(clientId);
|
|
223
243
|
removeOAuthState(clientId);
|
|
244
|
+
removeOAuthNonce(clientId);
|
|
224
245
|
removeReturnUrl(clientId);
|
|
225
246
|
removeLogoutState(clientId);
|
|
247
|
+
removeSilentProbe(clientId);
|
|
226
248
|
removePkceVerifier(clientId);
|
|
227
249
|
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
228
250
|
return;
|
|
229
251
|
}
|
|
230
252
|
removeTokenData();
|
|
231
253
|
removeOAuthState();
|
|
254
|
+
removeOAuthNonce();
|
|
232
255
|
removeReturnUrl();
|
|
233
256
|
removeLogoutState();
|
|
257
|
+
removeSilentProbe();
|
|
234
258
|
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
235
259
|
const stores = [
|
|
236
260
|
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
@@ -314,18 +338,21 @@ function normalizeIssuer(url) {
|
|
|
314
338
|
var cachedJwks = null;
|
|
315
339
|
var cachedDiscovery = null;
|
|
316
340
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
341
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
317
342
|
async function fetchDiscoveryDoc(baseUrl) {
|
|
318
343
|
const now = Date.now();
|
|
319
344
|
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
320
345
|
return cachedDiscovery.doc;
|
|
321
346
|
}
|
|
322
347
|
try {
|
|
323
|
-
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration
|
|
324
|
-
|
|
348
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
|
|
349
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) return null;
|
|
352
|
+
const doc = await res.json();
|
|
325
353
|
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
326
354
|
return doc;
|
|
327
355
|
} catch {
|
|
328
|
-
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
329
356
|
return null;
|
|
330
357
|
}
|
|
331
358
|
}
|
|
@@ -337,7 +364,10 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
337
364
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
338
365
|
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
339
366
|
try {
|
|
340
|
-
const res = await fetch(jwksUri,
|
|
367
|
+
const res = await fetch(jwksUri, {
|
|
368
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
369
|
+
...options.forceRefresh ? { cache: "no-cache" } : {}
|
|
370
|
+
});
|
|
341
371
|
if (!res.ok) return null;
|
|
342
372
|
const jwks = await res.json();
|
|
343
373
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -384,7 +414,7 @@ async function computeAtHash(accessToken) {
|
|
|
384
414
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
385
415
|
}
|
|
386
416
|
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
387
|
-
const { rejectHs256WhenRs256Available = true } = options;
|
|
417
|
+
const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
|
|
388
418
|
const header = decodeJwtHeader(idToken);
|
|
389
419
|
if (!header) {
|
|
390
420
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -474,10 +504,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
474
504
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
475
505
|
}
|
|
476
506
|
}
|
|
507
|
+
if (expectedNonce !== void 0) {
|
|
508
|
+
const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
|
|
509
|
+
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
510
|
+
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
477
513
|
return { sub: payload.sub };
|
|
478
514
|
}
|
|
479
515
|
|
|
480
516
|
// src/core/SsoClient.ts
|
|
517
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
518
|
+
"login_required",
|
|
519
|
+
"consent_required",
|
|
520
|
+
"interaction_required"
|
|
521
|
+
]);
|
|
481
522
|
var _SsoClient = class _SsoClient {
|
|
482
523
|
constructor(config) {
|
|
483
524
|
this._discovery = null;
|
|
@@ -571,17 +612,27 @@ var _SsoClient = class _SsoClient {
|
|
|
571
612
|
*
|
|
572
613
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
573
614
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
615
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
616
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
617
|
+
* handleCallback() 返回 null 而不抛错。
|
|
574
618
|
*
|
|
575
619
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
576
620
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
577
621
|
* 同一次登录只使用其中一个入口。
|
|
578
622
|
*/
|
|
579
|
-
async login(returnUrl) {
|
|
623
|
+
async login(returnUrl, options = {}) {
|
|
580
624
|
const verifier = generateCodeVerifier();
|
|
581
625
|
const challenge = await generateCodeChallenge(verifier);
|
|
582
626
|
const state = generateState();
|
|
627
|
+
const nonce = generateState();
|
|
583
628
|
savePkceVerifier(this.config.clientId, verifier);
|
|
584
629
|
saveOAuthState(state, this.config.clientId);
|
|
630
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
631
|
+
if (options.prompt === "none") {
|
|
632
|
+
saveSilentProbe(state, this.config.clientId);
|
|
633
|
+
} else {
|
|
634
|
+
removeSilentProbe(this.config.clientId);
|
|
635
|
+
}
|
|
585
636
|
if (returnUrl) {
|
|
586
637
|
this._saveReturnUrlIfTrusted(returnUrl);
|
|
587
638
|
}
|
|
@@ -592,8 +643,12 @@ var _SsoClient = class _SsoClient {
|
|
|
592
643
|
params.set("redirect_uri", this.config.redirectUri);
|
|
593
644
|
params.set("scope", this.config.scopes || "openid profile");
|
|
594
645
|
params.set("state", state);
|
|
646
|
+
params.set("nonce", nonce);
|
|
595
647
|
params.set("code_challenge", challenge);
|
|
596
648
|
params.set("code_challenge_method", "S256");
|
|
649
|
+
if (options.prompt) {
|
|
650
|
+
params.set("prompt", options.prompt);
|
|
651
|
+
}
|
|
597
652
|
window.location.href = `${authorizeEndpoint}?${params.toString()}`;
|
|
598
653
|
}
|
|
599
654
|
/**
|
|
@@ -605,12 +660,19 @@ var _SsoClient = class _SsoClient {
|
|
|
605
660
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
606
661
|
* 同一次登录只使用其中一个入口。
|
|
607
662
|
*/
|
|
608
|
-
async getLoginUrl(returnUrl) {
|
|
663
|
+
async getLoginUrl(returnUrl, options = {}) {
|
|
609
664
|
const verifier = generateCodeVerifier();
|
|
610
665
|
const challenge = await generateCodeChallenge(verifier);
|
|
611
666
|
const state = generateState();
|
|
667
|
+
const nonce = generateState();
|
|
612
668
|
savePkceVerifier(this.config.clientId, verifier);
|
|
613
669
|
saveOAuthState(state, this.config.clientId);
|
|
670
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
671
|
+
if (options.prompt === "none") {
|
|
672
|
+
saveSilentProbe(state, this.config.clientId);
|
|
673
|
+
} else {
|
|
674
|
+
removeSilentProbe(this.config.clientId);
|
|
675
|
+
}
|
|
614
676
|
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
615
677
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
616
678
|
const params = new URLSearchParams();
|
|
@@ -619,8 +681,12 @@ var _SsoClient = class _SsoClient {
|
|
|
619
681
|
params.set("redirect_uri", this.config.redirectUri);
|
|
620
682
|
params.set("scope", this.config.scopes || "openid profile");
|
|
621
683
|
params.set("state", state);
|
|
684
|
+
params.set("nonce", nonce);
|
|
622
685
|
params.set("code_challenge", challenge);
|
|
623
686
|
params.set("code_challenge_method", "S256");
|
|
687
|
+
if (options.prompt) {
|
|
688
|
+
params.set("prompt", options.prompt);
|
|
689
|
+
}
|
|
624
690
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
625
691
|
}
|
|
626
692
|
/**
|
|
@@ -660,9 +726,11 @@ var _SsoClient = class _SsoClient {
|
|
|
660
726
|
const verifier = generateCodeVerifier();
|
|
661
727
|
const challenge = await generateCodeChallenge(verifier);
|
|
662
728
|
const state = generateState();
|
|
729
|
+
const nonce = generateState();
|
|
663
730
|
const popupNonce = generateState();
|
|
664
731
|
savePkceVerifier(this.config.clientId, verifier);
|
|
665
732
|
saveOAuthState(state, this.config.clientId);
|
|
733
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
666
734
|
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
667
735
|
if (options.returnUrl) {
|
|
668
736
|
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
@@ -674,6 +742,7 @@ var _SsoClient = class _SsoClient {
|
|
|
674
742
|
params.set("redirect_uri", this.config.redirectUri);
|
|
675
743
|
params.set("scope", this.config.scopes || "openid profile");
|
|
676
744
|
params.set("state", state);
|
|
745
|
+
params.set("nonce", nonce);
|
|
677
746
|
params.set("code_challenge", challenge);
|
|
678
747
|
params.set("code_challenge_method", "S256");
|
|
679
748
|
params.set("popup_nonce", popupNonce);
|
|
@@ -698,6 +767,7 @@ var _SsoClient = class _SsoClient {
|
|
|
698
767
|
if (!popup) {
|
|
699
768
|
removePkceVerifier(this.config.clientId);
|
|
700
769
|
removeOAuthState(this.config.clientId);
|
|
770
|
+
removeOAuthNonce(this.config.clientId);
|
|
701
771
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
702
772
|
throw new SsoError(
|
|
703
773
|
"popup_blocked",
|
|
@@ -717,12 +787,27 @@ var _SsoClient = class _SsoClient {
|
|
|
717
787
|
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
718
788
|
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
719
789
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
790
|
+
try {
|
|
791
|
+
event.source?.postMessage(
|
|
792
|
+
{ type: "nihplod_sso_popup_ack", nonce: savedNonce },
|
|
793
|
+
event.origin
|
|
794
|
+
);
|
|
795
|
+
} catch {
|
|
796
|
+
}
|
|
720
797
|
completed = true;
|
|
721
798
|
cleanup();
|
|
722
799
|
if (popup && !popup.closed) {
|
|
723
800
|
popup.close();
|
|
724
801
|
}
|
|
725
|
-
this.handleCallback(event.data.callbackUrl).then(
|
|
802
|
+
this.handleCallback(event.data.callbackUrl).then((tokenData) => {
|
|
803
|
+
if (tokenData === null) {
|
|
804
|
+
reject(
|
|
805
|
+
new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
|
|
806
|
+
);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
resolve(tokenData);
|
|
810
|
+
}).catch(reject);
|
|
726
811
|
};
|
|
727
812
|
const pollTimer = setInterval(() => {
|
|
728
813
|
if (popup.closed) {
|
|
@@ -748,6 +833,7 @@ var _SsoClient = class _SsoClient {
|
|
|
748
833
|
} catch (err) {
|
|
749
834
|
removePkceVerifier(this.config.clientId);
|
|
750
835
|
removeOAuthState(this.config.clientId);
|
|
836
|
+
removeOAuthNonce(this.config.clientId);
|
|
751
837
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
752
838
|
throw err;
|
|
753
839
|
}
|
|
@@ -759,15 +845,29 @@ var _SsoClient = class _SsoClient {
|
|
|
759
845
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
760
846
|
*
|
|
761
847
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
762
|
-
* @returns TokenData
|
|
848
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
849
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
850
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
763
851
|
*/
|
|
764
852
|
async handleCallback(callbackUrl) {
|
|
765
853
|
const url = new URL(callbackUrl);
|
|
766
854
|
const params = url.searchParams;
|
|
767
855
|
const error = params.get("error");
|
|
768
856
|
if (error) {
|
|
857
|
+
const returnedErrorState = params.get("state");
|
|
858
|
+
const probeState = getSilentProbe(this.config.clientId);
|
|
859
|
+
const savedStateForProbe = getOAuthState(this.config.clientId);
|
|
860
|
+
if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
|
|
861
|
+
removeOAuthState(this.config.clientId);
|
|
862
|
+
removeOAuthNonce(this.config.clientId);
|
|
863
|
+
removePkceVerifier(this.config.clientId);
|
|
864
|
+
removeSilentProbe(this.config.clientId);
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
769
867
|
removeOAuthState(this.config.clientId);
|
|
868
|
+
removeOAuthNonce(this.config.clientId);
|
|
770
869
|
removePkceVerifier(this.config.clientId);
|
|
870
|
+
removeSilentProbe(this.config.clientId);
|
|
771
871
|
const desc = params.get("error_description") || error;
|
|
772
872
|
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
773
873
|
}
|
|
@@ -775,18 +875,23 @@ var _SsoClient = class _SsoClient {
|
|
|
775
875
|
const returnedState = params.get("state");
|
|
776
876
|
if (!code) {
|
|
777
877
|
removeOAuthState(this.config.clientId);
|
|
878
|
+
removeOAuthNonce(this.config.clientId);
|
|
778
879
|
removePkceVerifier(this.config.clientId);
|
|
880
|
+
removeSilentProbe(this.config.clientId);
|
|
779
881
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
780
882
|
}
|
|
781
883
|
const savedState = getOAuthState(this.config.clientId);
|
|
782
884
|
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
783
885
|
removeOAuthState(this.config.clientId);
|
|
886
|
+
removeOAuthNonce(this.config.clientId);
|
|
784
887
|
removePkceVerifier(this.config.clientId);
|
|
888
|
+
removeSilentProbe(this.config.clientId);
|
|
785
889
|
throw new SsoError(
|
|
786
890
|
"state_mismatch",
|
|
787
891
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
788
892
|
);
|
|
789
893
|
}
|
|
894
|
+
const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
|
|
790
895
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
791
896
|
if (!verifier) {
|
|
792
897
|
throw new SsoError(
|
|
@@ -833,7 +938,8 @@ var _SsoClient = class _SsoClient {
|
|
|
833
938
|
data.id_token,
|
|
834
939
|
data.access_token,
|
|
835
940
|
this.config.ssoBaseUrl,
|
|
836
|
-
this.config.clientId
|
|
941
|
+
this.config.clientId,
|
|
942
|
+
{ expectedNonce }
|
|
837
943
|
);
|
|
838
944
|
} catch (err) {
|
|
839
945
|
removeTokenData(this.config.clientId);
|
|
@@ -841,7 +947,9 @@ var _SsoClient = class _SsoClient {
|
|
|
841
947
|
}
|
|
842
948
|
}
|
|
843
949
|
removeOAuthState(this.config.clientId);
|
|
950
|
+
removeOAuthNonce(this.config.clientId);
|
|
844
951
|
removePkceVerifier(this.config.clientId);
|
|
952
|
+
removeSilentProbe(this.config.clientId);
|
|
845
953
|
const now = Date.now();
|
|
846
954
|
const tokenData = {
|
|
847
955
|
access_token: data.access_token,
|
|
@@ -1192,7 +1300,9 @@ function SsoProvider({
|
|
|
1192
1300
|
setError(
|
|
1193
1301
|
err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
|
|
1194
1302
|
);
|
|
1195
|
-
|
|
1303
|
+
if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
|
|
1304
|
+
removeTokenData(client.config.clientId);
|
|
1305
|
+
}
|
|
1196
1306
|
} finally {
|
|
1197
1307
|
setIsLoading(false);
|
|
1198
1308
|
}
|
|
@@ -1429,6 +1539,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1429
1539
|
const { client, refreshUser } = useSso();
|
|
1430
1540
|
const [error, setError] = (0, import_react3.useState)(null);
|
|
1431
1541
|
const [processing, setProcessing] = (0, import_react3.useState)(true);
|
|
1542
|
+
const [awaitingManualClose, setAwaitingManualClose] = (0, import_react3.useState)(false);
|
|
1432
1543
|
const onSuccessRef = (0, import_react3.useRef)(onSuccess);
|
|
1433
1544
|
const onErrorRef = (0, import_react3.useRef)(onError);
|
|
1434
1545
|
(0, import_react3.useEffect)(() => {
|
|
@@ -1443,18 +1554,52 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1443
1554
|
targetOrigin = window.opener.location.origin;
|
|
1444
1555
|
} catch {
|
|
1445
1556
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1557
|
+
const message = {
|
|
1558
|
+
type: "nihplod_sso_popup_callback",
|
|
1559
|
+
callbackUrl: window.location.href,
|
|
1560
|
+
nonce: nonce || void 0
|
|
1561
|
+
};
|
|
1562
|
+
let acked = false;
|
|
1563
|
+
const send = () => {
|
|
1564
|
+
if (acked || !window.opener || window.opener.closed) return;
|
|
1565
|
+
window.opener.postMessage(message, targetOrigin);
|
|
1566
|
+
};
|
|
1567
|
+
send();
|
|
1568
|
+
const resendTimer = setInterval(send, 500);
|
|
1569
|
+
const ackTimeout = setTimeout(() => {
|
|
1570
|
+
if (acked) return;
|
|
1571
|
+
clearInterval(resendTimer);
|
|
1572
|
+
setAwaitingManualClose(true);
|
|
1573
|
+
}, 1e4);
|
|
1574
|
+
const handleAck = (event) => {
|
|
1575
|
+
if (event.origin !== targetOrigin) return;
|
|
1576
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
|
|
1577
|
+
if (nonce && event.data.nonce !== nonce) return;
|
|
1578
|
+
acked = true;
|
|
1579
|
+
clearInterval(resendTimer);
|
|
1580
|
+
clearTimeout(ackTimeout);
|
|
1581
|
+
};
|
|
1582
|
+
window.addEventListener("message", handleAck);
|
|
1450
1583
|
Promise.resolve().then(() => setProcessing(false));
|
|
1451
|
-
return
|
|
1584
|
+
return () => {
|
|
1585
|
+
clearInterval(resendTimer);
|
|
1586
|
+
clearTimeout(ackTimeout);
|
|
1587
|
+
window.removeEventListener("message", handleAck);
|
|
1588
|
+
};
|
|
1452
1589
|
}
|
|
1453
1590
|
let cancelled = false;
|
|
1454
1591
|
async function handleCallback() {
|
|
1455
1592
|
try {
|
|
1456
1593
|
const tokenData = await client.handleCallback(window.location.href);
|
|
1457
1594
|
if (cancelled) return;
|
|
1595
|
+
if (tokenData === null) {
|
|
1596
|
+
const clientId2 = client.config.clientId;
|
|
1597
|
+
const probeReturnUrl = getReturnUrl(clientId2);
|
|
1598
|
+
removeReturnUrl(clientId2);
|
|
1599
|
+
const target = probeReturnUrl && isTrustedReturnUrl(probeReturnUrl, window.location.origin) ? probeReturnUrl : "/";
|
|
1600
|
+
window.location.href = target + (target.includes("?") ? "&" : "?") + "sso_probe=no_session";
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1458
1603
|
await refreshUser();
|
|
1459
1604
|
if (cancelled) return;
|
|
1460
1605
|
const clientId = client.config.clientId;
|
|
@@ -1487,6 +1632,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1487
1632
|
if (renderError) return import_react3.default.createElement(import_react3.default.Fragment, null, renderError(error));
|
|
1488
1633
|
return import_react3.default.createElement(DefaultCallbackError, { error });
|
|
1489
1634
|
}
|
|
1635
|
+
if (awaitingManualClose) {
|
|
1636
|
+
return import_react3.default.createElement(
|
|
1637
|
+
"div",
|
|
1638
|
+
{
|
|
1639
|
+
style: {
|
|
1640
|
+
display: "flex",
|
|
1641
|
+
alignItems: "center",
|
|
1642
|
+
justifyContent: "center",
|
|
1643
|
+
minHeight: "100vh",
|
|
1644
|
+
fontFamily: "system-ui, sans-serif"
|
|
1645
|
+
}
|
|
1646
|
+
},
|
|
1647
|
+
import_react3.default.createElement("p", null, "\u767B\u5F55\u5DF2\u5B8C\u6210\uFF0C\u8BF7\u624B\u52A8\u5173\u95ED\u6B64\u7A97\u53E3")
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1490
1650
|
if (processing) {
|
|
1491
1651
|
return import_react3.default.createElement(
|
|
1492
1652
|
"div",
|