@nihplod/sso-sdk 1.3.1 → 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 CHANGED
@@ -50,20 +50,38 @@ Create an SSO client instance.
50
50
  | `scopes` | `string` | ❌ | Space-separated scopes, default `"openid profile"` |
51
51
  | `clientSecret` | `string` | ❌ | **Only for Confidential Clients**. Do NOT pass this in browser SPA (Public Client) to avoid leaking secrets. BFF / Next.js Route Handlers may pass it. |
52
52
 
53
- ### `sso.login(returnUrl?)`
53
+ ### `sso.login(returnUrl?, options?)`
54
54
 
55
55
  Initiate SSO login. Generates PKCE parameters and redirects to the SSO login page.
56
56
 
57
57
  | Parameter | Type | Description |
58
58
  |------|------|------|
59
59
  | `returnUrl` | `string` | Optional URL to return to after login |
60
+ | `options.prompt` | `"none" \| "login" \| "consent"` | Optional OIDC `prompt` parameter, passed through to the authorize URL |
60
61
 
61
- ### `sso.getLoginUrl(returnUrl?)`
62
+ ### `sso.getLoginUrl(returnUrl?, options?)`
62
63
 
63
- Build the login URL string without redirecting. Returns `Promise<string>`.
64
+ Build the login URL string without redirecting. Returns `Promise<string>`. Accepts the same `options.prompt` as `login()`.
64
65
 
65
66
  > ⚠️ Do NOT mix `getLoginUrl()` with `login()` for the same login attempt: both regenerate and overwrite the `state` / PKCE verifier in sessionStorage, so the flow started first will fail with a state mismatch. Use only one entry point per login.
66
67
 
68
+ #### Silent session probe (`prompt: "none"`)
69
+
70
+ Passing `prompt: "none"` starts a **silent probe**: if the user still has a session on the SSO center, the authorize endpoint redirects straight back with a code and login completes without UI; if not, the IdP redirects back with `error=login_required` (or `consent_required` / `interaction_required`). When you start the probe via `login()` / `getLoginUrl()`, the SDK records a probe marker alongside `state`; `handleCallback()` then recognizes the matching error callback, cleans up the transient data and returns `null` instead of throwing — treat `null` as "no SSO session". The default `<CallbackPage>` redirects back to your `returnUrl` with `sso_probe=no_session` appended.
71
+
72
+ ```typescript
73
+ // Triggered by an explicit user action (e.g. clicking "继续 NIHPLOD 账户"):
74
+ await sso.login("/dashboard", { prompt: "none" });
75
+
76
+ // On the page returnUrl points to:
77
+ const params = new URLSearchParams(location.search);
78
+ if (params.get("sso_probe") === "no_session") {
79
+ // No SSO session — fall back to the normal interactive login button
80
+ }
81
+ ```
82
+
83
+ > ⚠️ Do NOT auto-probe on page load: it round-trips every visitor through the SSO center and adds latency for users without a session. Always trigger it from a deliberate user action (click).
84
+
67
85
  ### `sso.handleCallback(callbackUrl)`
68
86
 
69
87
  Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens. When an `id_token` is present, the SDK verifies its signature, issuer, audience, expiry and `at_hash`, and — when the login was initiated via `login()` / `getLoginUrl()` / `loginPopup()` — also validates the OIDC `nonce` claim against the value generated at login time (constant-time comparison, fail-closed, protecting against ID Token replay).
@@ -72,7 +90,7 @@ Handle the OAuth callback. Parses `code` and `state` from the URL, validates sta
72
90
  |------|------|------|
73
91
  | `callbackUrl` | `string` | Full callback URL (`window.location.href`) |
74
92
 
75
- Returns `Promise<TokenData>`.
93
+ Returns `Promise<TokenData | null>` — `null` only when a silent probe (`prompt: "none"`) finds no SSO session (see above).
76
94
 
77
95
  ### `sso.refreshToken()`
78
96
 
@@ -361,7 +379,11 @@ const handler = createLogoutRouteHandler({
361
379
  ssoBaseUrl: "https://nihplod.cn",
362
380
  redirectUri: "https://yourapp.com/api/auth/callback",
363
381
  postLogoutRedirectUri: "https://yourapp.com/",
364
- redirectToSso: true,
382
+ // Logout scope: "local" (default) only signs out of THIS site (revoke
383
+ // refresh_token + clear cookies, redirect to the site home page);
384
+ // "global" additionally redirects to the SSO end-session endpoint to
385
+ // sign out of all NIHPLOD platforms (RP-Initiated Logout).
386
+ defaultScope: "local",
365
387
  });
366
388
 
367
389
  // Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
@@ -371,6 +393,25 @@ export const GET = handler;
371
393
  export const POST = handler;
372
394
  ```
373
395
 
396
+ **Logout scopes (default changed):** the GET confirmation page now includes a "同时退出所有 NIHPLOD 平台" checkbox; when checked, the form POSTs `global=1` and the handler performs a global logout (end-session redirect) regardless of `defaultScope`. A POST without a `global` field uses `defaultScope`. If you trigger logout from your own fetch call, add `global=1` to the form body to opt into a global logout.
397
+
398
+ > **Migration:** `redirectToSso` is deprecated and logs a one-time warning. `redirectToSso: true` → `defaultScope: "global"`; `redirectToSso: false` → `defaultScope: "local"`. Note the default changed from "redirect to SSO" to **local logout** — pass `defaultScope: "global"` to keep the old behavior.
399
+
400
+ ```typescript
401
+ // src/app/api/auth/backchannel-logout/route.ts
402
+ import { createBackchannelLogoutRouteHandler } from "@nihplod/sso-sdk/next";
403
+
404
+ export const POST = createBackchannelLogoutRouteHandler({
405
+ clientId: "...",
406
+ ssoBaseUrl: "https://nihplod.cn",
407
+ onLogout: async ({ sub, sid }) => {
408
+ // Clear your own server-side session for this user/session
409
+ },
410
+ });
411
+ ```
412
+
413
+ The backchannel handler receives `logout_token` pushes from the SSO center (OIDC Back-Channel Logout) when the user signs out globally or revokes consent, verifies the token (RS256 signature via JWKS, issuer/audience/expiry, `events` claim, `jti` replay protection), clears the SSO cookies, and calls your `onLogout` hook. If the hook throws, it responds 500 so the IdP retries delivery. **Remember to register this route's public URL as `backchannelLogoutUri` in the SSO admin console for your client.**
414
+
374
415
  Trigger the logout endpoint with a POST request (recommended):
375
416
 
376
417
  ```tsx
package/dist/index.d.mts CHANGED
@@ -134,6 +134,21 @@ interface SsoUser {
134
134
  total_spent?: number;
135
135
  total_points?: number;
136
136
  }
137
+ /** login() / getLoginUrl() 选项 */
138
+ interface LoginOptions {
139
+ /**
140
+ * OIDC prompt 参数:
141
+ * - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
142
+ * 无会话时 IdP 回跳 error=login_required(或 consent_required /
143
+ * interaction_required),handleCallback() 识别后返回 null 而不抛错
144
+ * (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
145
+ * ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
146
+ * 应由用户点击等明确动作触发。
147
+ * - "login":强制重新认证;
148
+ * - "consent":强制重新授权确认。
149
+ */
150
+ prompt?: "none" | "login" | "consent";
151
+ }
137
152
  /** Token 响应 */
138
153
  interface TokenResponse {
139
154
  access_token: string;
@@ -195,12 +210,15 @@ declare class SsoClient {
195
210
  *
196
211
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
197
212
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
213
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
214
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
215
+ * handleCallback() 返回 null 而不抛错。
198
216
  *
199
217
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
200
218
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
201
219
  * 同一次登录只使用其中一个入口。
202
220
  */
203
- login(returnUrl?: string): Promise<void>;
221
+ login(returnUrl?: string, options?: LoginOptions): Promise<void>;
204
222
  /**
205
223
  * 构建登录 URL(不跳转,返回 URL 字符串)
206
224
  *
@@ -210,7 +228,7 @@ declare class SsoClient {
210
228
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
211
229
  * 同一次登录只使用其中一个入口。
212
230
  */
213
- getLoginUrl(returnUrl?: string): Promise<string>;
231
+ getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
214
232
  /**
215
233
  * 弹窗模式 SSO 登录
216
234
  *
@@ -256,9 +274,11 @@ declare class SsoClient {
256
274
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
257
275
  *
258
276
  * @param callbackUrl - 完整的回调 URL(window.location.href)
259
- * @returns TokenData 或 null
277
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
278
+ * (IdP 回跳 login_required / consent_required / interaction_required
279
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
260
280
  */
261
- handleCallback(callbackUrl: string): Promise<TokenData>;
281
+ handleCallback(callbackUrl: string): Promise<TokenData | null>;
262
282
  /**
263
283
  * 刷新 Access Token
264
284
  *
@@ -359,7 +379,7 @@ declare function generateState(): string;
359
379
  * SSO SDK 错误类型
360
380
  */
361
381
  /** SSO 错误码 */
362
- 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";
382
+ 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";
363
383
  /**
364
384
  * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
365
385
  *
package/dist/index.d.ts CHANGED
@@ -134,6 +134,21 @@ interface SsoUser {
134
134
  total_spent?: number;
135
135
  total_points?: number;
136
136
  }
137
+ /** login() / getLoginUrl() 选项 */
138
+ interface LoginOptions {
139
+ /**
140
+ * OIDC prompt 参数:
141
+ * - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
142
+ * 无会话时 IdP 回跳 error=login_required(或 consent_required /
143
+ * interaction_required),handleCallback() 识别后返回 null 而不抛错
144
+ * (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
145
+ * ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
146
+ * 应由用户点击等明确动作触发。
147
+ * - "login":强制重新认证;
148
+ * - "consent":强制重新授权确认。
149
+ */
150
+ prompt?: "none" | "login" | "consent";
151
+ }
137
152
  /** Token 响应 */
138
153
  interface TokenResponse {
139
154
  access_token: string;
@@ -195,12 +210,15 @@ declare class SsoClient {
195
210
  *
196
211
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
197
212
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
213
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
214
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
215
+ * handleCallback() 返回 null 而不抛错。
198
216
  *
199
217
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
200
218
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
201
219
  * 同一次登录只使用其中一个入口。
202
220
  */
203
- login(returnUrl?: string): Promise<void>;
221
+ login(returnUrl?: string, options?: LoginOptions): Promise<void>;
204
222
  /**
205
223
  * 构建登录 URL(不跳转,返回 URL 字符串)
206
224
  *
@@ -210,7 +228,7 @@ declare class SsoClient {
210
228
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
211
229
  * 同一次登录只使用其中一个入口。
212
230
  */
213
- getLoginUrl(returnUrl?: string): Promise<string>;
231
+ getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
214
232
  /**
215
233
  * 弹窗模式 SSO 登录
216
234
  *
@@ -256,9 +274,11 @@ declare class SsoClient {
256
274
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
257
275
  *
258
276
  * @param callbackUrl - 完整的回调 URL(window.location.href)
259
- * @returns TokenData 或 null
277
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
278
+ * (IdP 回跳 login_required / consent_required / interaction_required
279
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
260
280
  */
261
- handleCallback(callbackUrl: string): Promise<TokenData>;
281
+ handleCallback(callbackUrl: string): Promise<TokenData | null>;
262
282
  /**
263
283
  * 刷新 Access Token
264
284
  *
@@ -359,7 +379,7 @@ declare function generateState(): string;
359
379
  * SSO SDK 错误类型
360
380
  */
361
381
  /** SSO 错误码 */
362
- 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";
382
+ 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";
363
383
  /**
364
384
  * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
365
385
  *
package/dist/index.js CHANGED
@@ -139,6 +139,7 @@ var STATE_KEY = "oauth_state";
139
139
  var NONCE_KEY = "oidc_nonce";
140
140
  var RETURN_URL_KEY = "return_url";
141
141
  var LOGOUT_STATE_KEY = "logout_state";
142
+ var SILENT_PROBE_KEY = "silent_probe";
142
143
  function buildKey(base, clientId) {
143
144
  return clientId ? `${base}:${clientId}` : base;
144
145
  }
@@ -275,6 +276,15 @@ function getReturnUrl(clientId) {
275
276
  function removeReturnUrl(clientId) {
276
277
  _transient.remove(buildKey(RETURN_URL_KEY, clientId));
277
278
  }
279
+ function saveSilentProbe(state, clientId) {
280
+ _transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
281
+ }
282
+ function getSilentProbe(clientId) {
283
+ return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
284
+ }
285
+ function removeSilentProbe(clientId) {
286
+ _transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
287
+ }
278
288
  function clearAllSsoData(clientId) {
279
289
  if (clientId) {
280
290
  removeTokenData(clientId);
@@ -282,6 +292,7 @@ function clearAllSsoData(clientId) {
282
292
  removeOAuthNonce(clientId);
283
293
  removeReturnUrl(clientId);
284
294
  removeLogoutState(clientId);
295
+ removeSilentProbe(clientId);
285
296
  removePkceVerifier(clientId);
286
297
  removePkceVerifier(`${clientId}_popup_nonce`);
287
298
  return;
@@ -291,6 +302,7 @@ function clearAllSsoData(clientId) {
291
302
  removeOAuthNonce();
292
303
  removeReturnUrl();
293
304
  removeLogoutState();
305
+ removeSilentProbe();
294
306
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
295
307
  const stores = [
296
308
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -555,6 +567,11 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
555
567
  }
556
568
 
557
569
  // src/core/SsoClient.ts
570
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
571
+ "login_required",
572
+ "consent_required",
573
+ "interaction_required"
574
+ ]);
558
575
  var _SsoClient = class _SsoClient {
559
576
  constructor(config) {
560
577
  this._discovery = null;
@@ -648,12 +665,15 @@ var _SsoClient = class _SsoClient {
648
665
  *
649
666
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
650
667
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
668
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
669
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
670
+ * handleCallback() 返回 null 而不抛错。
651
671
  *
652
672
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
653
673
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
654
674
  * 同一次登录只使用其中一个入口。
655
675
  */
656
- async login(returnUrl) {
676
+ async login(returnUrl, options = {}) {
657
677
  const verifier = generateCodeVerifier();
658
678
  const challenge = await generateCodeChallenge(verifier);
659
679
  const state = generateState();
@@ -661,6 +681,11 @@ var _SsoClient = class _SsoClient {
661
681
  savePkceVerifier(this.config.clientId, verifier);
662
682
  saveOAuthState(state, this.config.clientId);
663
683
  saveOAuthNonce(nonce, this.config.clientId);
684
+ if (options.prompt === "none") {
685
+ saveSilentProbe(state, this.config.clientId);
686
+ } else {
687
+ removeSilentProbe(this.config.clientId);
688
+ }
664
689
  if (returnUrl) {
665
690
  this._saveReturnUrlIfTrusted(returnUrl);
666
691
  }
@@ -674,6 +699,9 @@ var _SsoClient = class _SsoClient {
674
699
  params.set("nonce", nonce);
675
700
  params.set("code_challenge", challenge);
676
701
  params.set("code_challenge_method", "S256");
702
+ if (options.prompt) {
703
+ params.set("prompt", options.prompt);
704
+ }
677
705
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
678
706
  }
679
707
  /**
@@ -685,7 +713,7 @@ var _SsoClient = class _SsoClient {
685
713
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
686
714
  * 同一次登录只使用其中一个入口。
687
715
  */
688
- async getLoginUrl(returnUrl) {
716
+ async getLoginUrl(returnUrl, options = {}) {
689
717
  const verifier = generateCodeVerifier();
690
718
  const challenge = await generateCodeChallenge(verifier);
691
719
  const state = generateState();
@@ -693,6 +721,11 @@ var _SsoClient = class _SsoClient {
693
721
  savePkceVerifier(this.config.clientId, verifier);
694
722
  saveOAuthState(state, this.config.clientId);
695
723
  saveOAuthNonce(nonce, this.config.clientId);
724
+ if (options.prompt === "none") {
725
+ saveSilentProbe(state, this.config.clientId);
726
+ } else {
727
+ removeSilentProbe(this.config.clientId);
728
+ }
696
729
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
697
730
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
698
731
  const params = new URLSearchParams();
@@ -704,6 +737,9 @@ var _SsoClient = class _SsoClient {
704
737
  params.set("nonce", nonce);
705
738
  params.set("code_challenge", challenge);
706
739
  params.set("code_challenge_method", "S256");
740
+ if (options.prompt) {
741
+ params.set("prompt", options.prompt);
742
+ }
707
743
  return `${authorizeEndpoint}?${params.toString()}`;
708
744
  }
709
745
  /**
@@ -816,7 +852,15 @@ var _SsoClient = class _SsoClient {
816
852
  if (popup && !popup.closed) {
817
853
  popup.close();
818
854
  }
819
- this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
855
+ this.handleCallback(event.data.callbackUrl).then((tokenData) => {
856
+ if (tokenData === null) {
857
+ reject(
858
+ new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
859
+ );
860
+ return;
861
+ }
862
+ resolve(tokenData);
863
+ }).catch(reject);
820
864
  };
821
865
  const pollTimer = setInterval(() => {
822
866
  if (popup.closed) {
@@ -854,16 +898,29 @@ var _SsoClient = class _SsoClient {
854
898
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
855
899
  *
856
900
  * @param callbackUrl - 完整的回调 URL(window.location.href)
857
- * @returns TokenData 或 null
901
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
902
+ * (IdP 回跳 login_required / consent_required / interaction_required
903
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
858
904
  */
859
905
  async handleCallback(callbackUrl) {
860
906
  const url = new URL(callbackUrl);
861
907
  const params = url.searchParams;
862
908
  const error = params.get("error");
863
909
  if (error) {
910
+ const returnedErrorState = params.get("state");
911
+ const probeState = getSilentProbe(this.config.clientId);
912
+ const savedStateForProbe = getOAuthState(this.config.clientId);
913
+ if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
914
+ removeOAuthState(this.config.clientId);
915
+ removeOAuthNonce(this.config.clientId);
916
+ removePkceVerifier(this.config.clientId);
917
+ removeSilentProbe(this.config.clientId);
918
+ return null;
919
+ }
864
920
  removeOAuthState(this.config.clientId);
865
921
  removeOAuthNonce(this.config.clientId);
866
922
  removePkceVerifier(this.config.clientId);
923
+ removeSilentProbe(this.config.clientId);
867
924
  const desc = params.get("error_description") || error;
868
925
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
869
926
  }
@@ -873,6 +930,7 @@ var _SsoClient = class _SsoClient {
873
930
  removeOAuthState(this.config.clientId);
874
931
  removeOAuthNonce(this.config.clientId);
875
932
  removePkceVerifier(this.config.clientId);
933
+ removeSilentProbe(this.config.clientId);
876
934
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
877
935
  }
878
936
  const savedState = getOAuthState(this.config.clientId);
@@ -880,6 +938,7 @@ var _SsoClient = class _SsoClient {
880
938
  removeOAuthState(this.config.clientId);
881
939
  removeOAuthNonce(this.config.clientId);
882
940
  removePkceVerifier(this.config.clientId);
941
+ removeSilentProbe(this.config.clientId);
883
942
  throw new SsoError(
884
943
  "state_mismatch",
885
944
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
@@ -943,6 +1002,7 @@ var _SsoClient = class _SsoClient {
943
1002
  removeOAuthState(this.config.clientId);
944
1003
  removeOAuthNonce(this.config.clientId);
945
1004
  removePkceVerifier(this.config.clientId);
1005
+ removeSilentProbe(this.config.clientId);
946
1006
  const now = Date.now();
947
1007
  const tokenData = {
948
1008
  access_token: data.access_token,