@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/dist/index.mjs CHANGED
@@ -82,8 +82,10 @@ var STORAGE_PREFIX = "nihplod_sso_";
82
82
  var TOKEN_KEY = "token";
83
83
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
84
84
  var STATE_KEY = "oauth_state";
85
+ var NONCE_KEY = "oidc_nonce";
85
86
  var RETURN_URL_KEY = "return_url";
86
87
  var LOGOUT_STATE_KEY = "logout_state";
88
+ var SILENT_PROBE_KEY = "silent_probe";
87
89
  function buildKey(base, clientId) {
88
90
  return clientId ? `${base}:${clientId}` : base;
89
91
  }
@@ -193,6 +195,15 @@ function getOAuthState(clientId) {
193
195
  function removeOAuthState(clientId) {
194
196
  _transient.remove(buildKey(STATE_KEY, clientId));
195
197
  }
198
+ function saveOAuthNonce(nonce, clientId) {
199
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
200
+ }
201
+ function getOAuthNonce(clientId) {
202
+ return _transient.get(buildKey(NONCE_KEY, clientId));
203
+ }
204
+ function removeOAuthNonce(clientId) {
205
+ _transient.remove(buildKey(NONCE_KEY, clientId));
206
+ }
196
207
  function saveLogoutState(state, clientId) {
197
208
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
198
209
  }
@@ -211,20 +222,33 @@ function getReturnUrl(clientId) {
211
222
  function removeReturnUrl(clientId) {
212
223
  _transient.remove(buildKey(RETURN_URL_KEY, clientId));
213
224
  }
225
+ function saveSilentProbe(state, clientId) {
226
+ _transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
227
+ }
228
+ function getSilentProbe(clientId) {
229
+ return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
230
+ }
231
+ function removeSilentProbe(clientId) {
232
+ _transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
233
+ }
214
234
  function clearAllSsoData(clientId) {
215
235
  if (clientId) {
216
236
  removeTokenData(clientId);
217
237
  removeOAuthState(clientId);
238
+ removeOAuthNonce(clientId);
218
239
  removeReturnUrl(clientId);
219
240
  removeLogoutState(clientId);
241
+ removeSilentProbe(clientId);
220
242
  removePkceVerifier(clientId);
221
243
  removePkceVerifier(`${clientId}_popup_nonce`);
222
244
  return;
223
245
  }
224
246
  removeTokenData();
225
247
  removeOAuthState();
248
+ removeOAuthNonce();
226
249
  removeReturnUrl();
227
250
  removeLogoutState();
251
+ removeSilentProbe();
228
252
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
229
253
  const stores = [
230
254
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -313,18 +337,21 @@ function normalizeIssuer(url) {
313
337
  var cachedJwks = null;
314
338
  var cachedDiscovery = null;
315
339
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
340
+ var FETCH_TIMEOUT_MS = 1e4;
316
341
  async function fetchDiscoveryDoc(baseUrl) {
317
342
  const now = Date.now();
318
343
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
319
344
  return cachedDiscovery.doc;
320
345
  }
321
346
  try {
322
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
323
- const doc = res.ok ? await res.json() : null;
347
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
348
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
349
+ });
350
+ if (!res.ok) return null;
351
+ const doc = await res.json();
324
352
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
325
353
  return doc;
326
354
  } catch {
327
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
328
355
  return null;
329
356
  }
330
357
  }
@@ -336,7 +363,10 @@ async function fetchJwks(baseUrl, options = {}) {
336
363
  const discovery = await fetchDiscoveryDoc(baseUrl);
337
364
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
338
365
  try {
339
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
366
+ const res = await fetch(jwksUri, {
367
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
368
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
369
+ });
340
370
  if (!res.ok) return null;
341
371
  const jwks = await res.json();
342
372
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -383,7 +413,7 @@ async function computeAtHash(accessToken) {
383
413
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
384
414
  }
385
415
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
386
- const { rejectHs256WhenRs256Available = true } = options;
416
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
387
417
  const header = decodeJwtHeader(idToken);
388
418
  if (!header) {
389
419
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -473,10 +503,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
473
503
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
474
504
  }
475
505
  }
506
+ if (expectedNonce !== void 0) {
507
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
508
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
509
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
510
+ }
511
+ }
476
512
  return { sub: payload.sub };
477
513
  }
478
514
 
479
515
  // src/core/SsoClient.ts
516
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
517
+ "login_required",
518
+ "consent_required",
519
+ "interaction_required"
520
+ ]);
480
521
  var _SsoClient = class _SsoClient {
481
522
  constructor(config) {
482
523
  this._discovery = null;
@@ -570,17 +611,27 @@ var _SsoClient = class _SsoClient {
570
611
  *
571
612
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
572
613
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
614
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
615
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
616
+ * handleCallback() 返回 null 而不抛错。
573
617
  *
574
618
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
575
619
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
576
620
  * 同一次登录只使用其中一个入口。
577
621
  */
578
- async login(returnUrl) {
622
+ async login(returnUrl, options = {}) {
579
623
  const verifier = generateCodeVerifier();
580
624
  const challenge = await generateCodeChallenge(verifier);
581
625
  const state = generateState();
626
+ const nonce = generateState();
582
627
  savePkceVerifier(this.config.clientId, verifier);
583
628
  saveOAuthState(state, this.config.clientId);
629
+ saveOAuthNonce(nonce, this.config.clientId);
630
+ if (options.prompt === "none") {
631
+ saveSilentProbe(state, this.config.clientId);
632
+ } else {
633
+ removeSilentProbe(this.config.clientId);
634
+ }
584
635
  if (returnUrl) {
585
636
  this._saveReturnUrlIfTrusted(returnUrl);
586
637
  }
@@ -591,8 +642,12 @@ var _SsoClient = class _SsoClient {
591
642
  params.set("redirect_uri", this.config.redirectUri);
592
643
  params.set("scope", this.config.scopes || "openid profile");
593
644
  params.set("state", state);
645
+ params.set("nonce", nonce);
594
646
  params.set("code_challenge", challenge);
595
647
  params.set("code_challenge_method", "S256");
648
+ if (options.prompt) {
649
+ params.set("prompt", options.prompt);
650
+ }
596
651
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
597
652
  }
598
653
  /**
@@ -604,12 +659,19 @@ var _SsoClient = class _SsoClient {
604
659
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
605
660
  * 同一次登录只使用其中一个入口。
606
661
  */
607
- async getLoginUrl(returnUrl) {
662
+ async getLoginUrl(returnUrl, options = {}) {
608
663
  const verifier = generateCodeVerifier();
609
664
  const challenge = await generateCodeChallenge(verifier);
610
665
  const state = generateState();
666
+ const nonce = generateState();
611
667
  savePkceVerifier(this.config.clientId, verifier);
612
668
  saveOAuthState(state, this.config.clientId);
669
+ saveOAuthNonce(nonce, this.config.clientId);
670
+ if (options.prompt === "none") {
671
+ saveSilentProbe(state, this.config.clientId);
672
+ } else {
673
+ removeSilentProbe(this.config.clientId);
674
+ }
613
675
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
614
676
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
615
677
  const params = new URLSearchParams();
@@ -618,8 +680,12 @@ var _SsoClient = class _SsoClient {
618
680
  params.set("redirect_uri", this.config.redirectUri);
619
681
  params.set("scope", this.config.scopes || "openid profile");
620
682
  params.set("state", state);
683
+ params.set("nonce", nonce);
621
684
  params.set("code_challenge", challenge);
622
685
  params.set("code_challenge_method", "S256");
686
+ if (options.prompt) {
687
+ params.set("prompt", options.prompt);
688
+ }
623
689
  return `${authorizeEndpoint}?${params.toString()}`;
624
690
  }
625
691
  /**
@@ -659,9 +725,11 @@ var _SsoClient = class _SsoClient {
659
725
  const verifier = generateCodeVerifier();
660
726
  const challenge = await generateCodeChallenge(verifier);
661
727
  const state = generateState();
728
+ const nonce = generateState();
662
729
  const popupNonce = generateState();
663
730
  savePkceVerifier(this.config.clientId, verifier);
664
731
  saveOAuthState(state, this.config.clientId);
732
+ saveOAuthNonce(nonce, this.config.clientId);
665
733
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
666
734
  if (options.returnUrl) {
667
735
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -673,6 +741,7 @@ var _SsoClient = class _SsoClient {
673
741
  params.set("redirect_uri", this.config.redirectUri);
674
742
  params.set("scope", this.config.scopes || "openid profile");
675
743
  params.set("state", state);
744
+ params.set("nonce", nonce);
676
745
  params.set("code_challenge", challenge);
677
746
  params.set("code_challenge_method", "S256");
678
747
  params.set("popup_nonce", popupNonce);
@@ -697,6 +766,7 @@ var _SsoClient = class _SsoClient {
697
766
  if (!popup) {
698
767
  removePkceVerifier(this.config.clientId);
699
768
  removeOAuthState(this.config.clientId);
769
+ removeOAuthNonce(this.config.clientId);
700
770
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
701
771
  throw new SsoError(
702
772
  "popup_blocked",
@@ -716,12 +786,27 @@ var _SsoClient = class _SsoClient {
716
786
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
717
787
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
718
788
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
789
+ try {
790
+ event.source?.postMessage(
791
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
792
+ event.origin
793
+ );
794
+ } catch {
795
+ }
719
796
  completed = true;
720
797
  cleanup();
721
798
  if (popup && !popup.closed) {
722
799
  popup.close();
723
800
  }
724
- this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
801
+ this.handleCallback(event.data.callbackUrl).then((tokenData) => {
802
+ if (tokenData === null) {
803
+ reject(
804
+ new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
805
+ );
806
+ return;
807
+ }
808
+ resolve(tokenData);
809
+ }).catch(reject);
725
810
  };
726
811
  const pollTimer = setInterval(() => {
727
812
  if (popup.closed) {
@@ -747,6 +832,7 @@ var _SsoClient = class _SsoClient {
747
832
  } catch (err) {
748
833
  removePkceVerifier(this.config.clientId);
749
834
  removeOAuthState(this.config.clientId);
835
+ removeOAuthNonce(this.config.clientId);
750
836
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
751
837
  throw err;
752
838
  }
@@ -758,15 +844,29 @@ var _SsoClient = class _SsoClient {
758
844
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
759
845
  *
760
846
  * @param callbackUrl - 完整的回调 URL(window.location.href)
761
- * @returns TokenData 或 null
847
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
848
+ * (IdP 回跳 login_required / consent_required / interaction_required
849
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
762
850
  */
763
851
  async handleCallback(callbackUrl) {
764
852
  const url = new URL(callbackUrl);
765
853
  const params = url.searchParams;
766
854
  const error = params.get("error");
767
855
  if (error) {
856
+ const returnedErrorState = params.get("state");
857
+ const probeState = getSilentProbe(this.config.clientId);
858
+ const savedStateForProbe = getOAuthState(this.config.clientId);
859
+ if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
860
+ removeOAuthState(this.config.clientId);
861
+ removeOAuthNonce(this.config.clientId);
862
+ removePkceVerifier(this.config.clientId);
863
+ removeSilentProbe(this.config.clientId);
864
+ return null;
865
+ }
768
866
  removeOAuthState(this.config.clientId);
867
+ removeOAuthNonce(this.config.clientId);
769
868
  removePkceVerifier(this.config.clientId);
869
+ removeSilentProbe(this.config.clientId);
770
870
  const desc = params.get("error_description") || error;
771
871
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
772
872
  }
@@ -774,18 +874,23 @@ var _SsoClient = class _SsoClient {
774
874
  const returnedState = params.get("state");
775
875
  if (!code) {
776
876
  removeOAuthState(this.config.clientId);
877
+ removeOAuthNonce(this.config.clientId);
777
878
  removePkceVerifier(this.config.clientId);
879
+ removeSilentProbe(this.config.clientId);
778
880
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
779
881
  }
780
882
  const savedState = getOAuthState(this.config.clientId);
781
883
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
782
884
  removeOAuthState(this.config.clientId);
885
+ removeOAuthNonce(this.config.clientId);
783
886
  removePkceVerifier(this.config.clientId);
887
+ removeSilentProbe(this.config.clientId);
784
888
  throw new SsoError(
785
889
  "state_mismatch",
786
890
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
787
891
  );
788
892
  }
893
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
789
894
  const verifier = getPkceVerifier(this.config.clientId);
790
895
  if (!verifier) {
791
896
  throw new SsoError(
@@ -832,7 +937,8 @@ var _SsoClient = class _SsoClient {
832
937
  data.id_token,
833
938
  data.access_token,
834
939
  this.config.ssoBaseUrl,
835
- this.config.clientId
940
+ this.config.clientId,
941
+ { expectedNonce }
836
942
  );
837
943
  } catch (err) {
838
944
  removeTokenData(this.config.clientId);
@@ -840,7 +946,9 @@ var _SsoClient = class _SsoClient {
840
946
  }
841
947
  }
842
948
  removeOAuthState(this.config.clientId);
949
+ removeOAuthNonce(this.config.clientId);
843
950
  removePkceVerifier(this.config.clientId);
951
+ removeSilentProbe(this.config.clientId);
844
952
  const now = Date.now();
845
953
  const tokenData = {
846
954
  access_token: data.access_token,