@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.
@@ -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 或 null
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
  */
@@ -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
- const doc = res.ok ? await res.json() : null;
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, options.forceRefresh ? { cache: "no-cache" } : void 0);
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(resolve).catch(reject);
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 或 null
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
- removeTokenData(client.config.clientId);
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
- window.opener.postMessage(
1447
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1448
- targetOrigin
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",