@nihplod/sso-sdk 1.3.1 → 1.4.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.
@@ -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" | "id_token_nonce_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
  */
@@ -120,6 +120,7 @@ var STATE_KEY = "oauth_state";
120
120
  var NONCE_KEY = "oidc_nonce";
121
121
  var RETURN_URL_KEY = "return_url";
122
122
  var LOGOUT_STATE_KEY = "logout_state";
123
+ var SILENT_PROBE_KEY = "silent_probe";
123
124
  function buildKey(base, clientId) {
124
125
  return clientId ? `${base}:${clientId}` : base;
125
126
  }
@@ -227,6 +228,15 @@ function getReturnUrl(clientId) {
227
228
  function removeReturnUrl(clientId) {
228
229
  _transient.remove(buildKey(RETURN_URL_KEY, clientId));
229
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
+ }
230
240
  function clearAllSsoData(clientId) {
231
241
  if (clientId) {
232
242
  removeTokenData(clientId);
@@ -234,6 +244,7 @@ function clearAllSsoData(clientId) {
234
244
  removeOAuthNonce(clientId);
235
245
  removeReturnUrl(clientId);
236
246
  removeLogoutState(clientId);
247
+ removeSilentProbe(clientId);
237
248
  removePkceVerifier(clientId);
238
249
  removePkceVerifier(`${clientId}_popup_nonce`);
239
250
  return;
@@ -243,6 +254,7 @@ function clearAllSsoData(clientId) {
243
254
  removeOAuthNonce();
244
255
  removeReturnUrl();
245
256
  removeLogoutState();
257
+ removeSilentProbe();
246
258
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
247
259
  const stores = [
248
260
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -502,6 +514,11 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
502
514
  }
503
515
 
504
516
  // src/core/SsoClient.ts
517
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
518
+ "login_required",
519
+ "consent_required",
520
+ "interaction_required"
521
+ ]);
505
522
  var _SsoClient = class _SsoClient {
506
523
  constructor(config) {
507
524
  this._discovery = null;
@@ -595,12 +612,15 @@ var _SsoClient = class _SsoClient {
595
612
  *
596
613
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
597
614
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
615
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
616
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
617
+ * handleCallback() 返回 null 而不抛错。
598
618
  *
599
619
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
600
620
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
601
621
  * 同一次登录只使用其中一个入口。
602
622
  */
603
- async login(returnUrl) {
623
+ async login(returnUrl, options = {}) {
604
624
  const verifier = generateCodeVerifier();
605
625
  const challenge = await generateCodeChallenge(verifier);
606
626
  const state = generateState();
@@ -608,6 +628,11 @@ var _SsoClient = class _SsoClient {
608
628
  savePkceVerifier(this.config.clientId, verifier);
609
629
  saveOAuthState(state, this.config.clientId);
610
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
+ }
611
636
  if (returnUrl) {
612
637
  this._saveReturnUrlIfTrusted(returnUrl);
613
638
  }
@@ -621,6 +646,9 @@ var _SsoClient = class _SsoClient {
621
646
  params.set("nonce", nonce);
622
647
  params.set("code_challenge", challenge);
623
648
  params.set("code_challenge_method", "S256");
649
+ if (options.prompt) {
650
+ params.set("prompt", options.prompt);
651
+ }
624
652
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
625
653
  }
626
654
  /**
@@ -632,7 +660,7 @@ var _SsoClient = class _SsoClient {
632
660
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
633
661
  * 同一次登录只使用其中一个入口。
634
662
  */
635
- async getLoginUrl(returnUrl) {
663
+ async getLoginUrl(returnUrl, options = {}) {
636
664
  const verifier = generateCodeVerifier();
637
665
  const challenge = await generateCodeChallenge(verifier);
638
666
  const state = generateState();
@@ -640,6 +668,11 @@ var _SsoClient = class _SsoClient {
640
668
  savePkceVerifier(this.config.clientId, verifier);
641
669
  saveOAuthState(state, this.config.clientId);
642
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
+ }
643
676
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
644
677
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
645
678
  const params = new URLSearchParams();
@@ -651,6 +684,9 @@ var _SsoClient = class _SsoClient {
651
684
  params.set("nonce", nonce);
652
685
  params.set("code_challenge", challenge);
653
686
  params.set("code_challenge_method", "S256");
687
+ if (options.prompt) {
688
+ params.set("prompt", options.prompt);
689
+ }
654
690
  return `${authorizeEndpoint}?${params.toString()}`;
655
691
  }
656
692
  /**
@@ -763,7 +799,15 @@ var _SsoClient = class _SsoClient {
763
799
  if (popup && !popup.closed) {
764
800
  popup.close();
765
801
  }
766
- 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);
767
811
  };
768
812
  const pollTimer = setInterval(() => {
769
813
  if (popup.closed) {
@@ -801,16 +845,29 @@ var _SsoClient = class _SsoClient {
801
845
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
802
846
  *
803
847
  * @param callbackUrl - 完整的回调 URL(window.location.href)
804
- * @returns TokenData 或 null
848
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
849
+ * (IdP 回跳 login_required / consent_required / interaction_required
850
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
805
851
  */
806
852
  async handleCallback(callbackUrl) {
807
853
  const url = new URL(callbackUrl);
808
854
  const params = url.searchParams;
809
855
  const error = params.get("error");
810
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
+ }
811
867
  removeOAuthState(this.config.clientId);
812
868
  removeOAuthNonce(this.config.clientId);
813
869
  removePkceVerifier(this.config.clientId);
870
+ removeSilentProbe(this.config.clientId);
814
871
  const desc = params.get("error_description") || error;
815
872
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
816
873
  }
@@ -820,6 +877,7 @@ var _SsoClient = class _SsoClient {
820
877
  removeOAuthState(this.config.clientId);
821
878
  removeOAuthNonce(this.config.clientId);
822
879
  removePkceVerifier(this.config.clientId);
880
+ removeSilentProbe(this.config.clientId);
823
881
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
824
882
  }
825
883
  const savedState = getOAuthState(this.config.clientId);
@@ -827,6 +885,7 @@ var _SsoClient = class _SsoClient {
827
885
  removeOAuthState(this.config.clientId);
828
886
  removeOAuthNonce(this.config.clientId);
829
887
  removePkceVerifier(this.config.clientId);
888
+ removeSilentProbe(this.config.clientId);
830
889
  throw new SsoError(
831
890
  "state_mismatch",
832
891
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
@@ -890,6 +949,7 @@ var _SsoClient = class _SsoClient {
890
949
  removeOAuthState(this.config.clientId);
891
950
  removeOAuthNonce(this.config.clientId);
892
951
  removePkceVerifier(this.config.clientId);
952
+ removeSilentProbe(this.config.clientId);
893
953
  const now = Date.now();
894
954
  const tokenData = {
895
955
  access_token: data.access_token,
@@ -1532,6 +1592,14 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1532
1592
  try {
1533
1593
  const tokenData = await client.handleCallback(window.location.href);
1534
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
+ }
1535
1603
  await refreshUser();
1536
1604
  if (cancelled) return;
1537
1605
  const clientId = client.config.clientId;