@nihplod/sso-sdk 1.2.1 → 1.3.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.
package/dist/index.mjs CHANGED
@@ -82,6 +82,7 @@ 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";
87
88
  function buildKey(base, clientId) {
@@ -193,6 +194,15 @@ function getOAuthState(clientId) {
193
194
  function removeOAuthState(clientId) {
194
195
  _transient.remove(buildKey(STATE_KEY, clientId));
195
196
  }
197
+ function saveOAuthNonce(nonce, clientId) {
198
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
199
+ }
200
+ function getOAuthNonce(clientId) {
201
+ return _transient.get(buildKey(NONCE_KEY, clientId));
202
+ }
203
+ function removeOAuthNonce(clientId) {
204
+ _transient.remove(buildKey(NONCE_KEY, clientId));
205
+ }
196
206
  function saveLogoutState(state, clientId) {
197
207
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
198
208
  }
@@ -215,6 +225,7 @@ function clearAllSsoData(clientId) {
215
225
  if (clientId) {
216
226
  removeTokenData(clientId);
217
227
  removeOAuthState(clientId);
228
+ removeOAuthNonce(clientId);
218
229
  removeReturnUrl(clientId);
219
230
  removeLogoutState(clientId);
220
231
  removePkceVerifier(clientId);
@@ -223,6 +234,7 @@ function clearAllSsoData(clientId) {
223
234
  }
224
235
  removeTokenData();
225
236
  removeOAuthState();
237
+ removeOAuthNonce();
226
238
  removeReturnUrl();
227
239
  removeLogoutState();
228
240
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
@@ -313,18 +325,21 @@ function normalizeIssuer(url) {
313
325
  var cachedJwks = null;
314
326
  var cachedDiscovery = null;
315
327
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
328
+ var FETCH_TIMEOUT_MS = 1e4;
316
329
  async function fetchDiscoveryDoc(baseUrl) {
317
330
  const now = Date.now();
318
331
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
319
332
  return cachedDiscovery.doc;
320
333
  }
321
334
  try {
322
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
323
- const doc = res.ok ? await res.json() : null;
335
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
336
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
337
+ });
338
+ if (!res.ok) return null;
339
+ const doc = await res.json();
324
340
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
325
341
  return doc;
326
342
  } catch {
327
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
328
343
  return null;
329
344
  }
330
345
  }
@@ -336,7 +351,10 @@ async function fetchJwks(baseUrl, options = {}) {
336
351
  const discovery = await fetchDiscoveryDoc(baseUrl);
337
352
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
338
353
  try {
339
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
354
+ const res = await fetch(jwksUri, {
355
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
356
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
357
+ });
340
358
  if (!res.ok) return null;
341
359
  const jwks = await res.json();
342
360
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -383,7 +401,7 @@ async function computeAtHash(accessToken) {
383
401
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
384
402
  }
385
403
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
386
- const { rejectHs256WhenRs256Available = true } = options;
404
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
387
405
  const header = decodeJwtHeader(idToken);
388
406
  if (!header) {
389
407
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -473,6 +491,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
473
491
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
474
492
  }
475
493
  }
494
+ if (expectedNonce !== void 0) {
495
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
496
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
497
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
498
+ }
499
+ }
476
500
  return { sub: payload.sub };
477
501
  }
478
502
 
@@ -487,6 +511,7 @@ var _SsoClient = class _SsoClient {
487
511
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
488
512
  const base = config.ssoBaseUrl.replace(/\/+$/, "");
489
513
  this.config = { ...config, ssoBaseUrl: base };
514
+ this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
490
515
  }
491
516
  // ============================================
492
517
  // 内部方法
@@ -502,7 +527,7 @@ var _SsoClient = class _SsoClient {
502
527
  if (this._discovery && now - this._discoveryFetchedAt < _SsoClient.DISCOVERY_TTL_MS) {
503
528
  return this._discovery;
504
529
  }
505
- const url = `${this.config.ssoBaseUrl}/api/oauth/.well-known/openid-configuration`;
530
+ const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
506
531
  try {
507
532
  const controller = new AbortController();
508
533
  const timeoutId = setTimeout(
@@ -533,14 +558,16 @@ var _SsoClient = class _SsoClient {
533
558
  if (d) return d.authorization_endpoint;
534
559
  return `${this.config.ssoBaseUrl}/api/oauth/authorize`;
535
560
  }
536
- /** 获取 token 端点 URL(优先 Discovery,回退默认) */
561
+ /** 获取 token 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
537
562
  async _getTokenEndpoint() {
563
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/token`;
538
564
  const d = await this._getDiscovery();
539
565
  if (d) return d.token_endpoint;
540
566
  return `${this.config.ssoBaseUrl}/api/oauth/token`;
541
567
  }
542
- /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
568
+ /** 获取 userinfo 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
543
569
  async _getUserinfoEndpoint() {
570
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/userinfo`;
544
571
  const d = await this._getDiscovery();
545
572
  if (d) return d.userinfo_endpoint;
546
573
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
@@ -576,8 +603,10 @@ var _SsoClient = class _SsoClient {
576
603
  const verifier = generateCodeVerifier();
577
604
  const challenge = await generateCodeChallenge(verifier);
578
605
  const state = generateState();
606
+ const nonce = generateState();
579
607
  savePkceVerifier(this.config.clientId, verifier);
580
608
  saveOAuthState(state, this.config.clientId);
609
+ saveOAuthNonce(nonce, this.config.clientId);
581
610
  if (returnUrl) {
582
611
  this._saveReturnUrlIfTrusted(returnUrl);
583
612
  }
@@ -588,6 +617,7 @@ var _SsoClient = class _SsoClient {
588
617
  params.set("redirect_uri", this.config.redirectUri);
589
618
  params.set("scope", this.config.scopes || "openid profile");
590
619
  params.set("state", state);
620
+ params.set("nonce", nonce);
591
621
  params.set("code_challenge", challenge);
592
622
  params.set("code_challenge_method", "S256");
593
623
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
@@ -605,8 +635,10 @@ var _SsoClient = class _SsoClient {
605
635
  const verifier = generateCodeVerifier();
606
636
  const challenge = await generateCodeChallenge(verifier);
607
637
  const state = generateState();
638
+ const nonce = generateState();
608
639
  savePkceVerifier(this.config.clientId, verifier);
609
640
  saveOAuthState(state, this.config.clientId);
641
+ saveOAuthNonce(nonce, this.config.clientId);
610
642
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
611
643
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
612
644
  const params = new URLSearchParams();
@@ -615,6 +647,7 @@ var _SsoClient = class _SsoClient {
615
647
  params.set("redirect_uri", this.config.redirectUri);
616
648
  params.set("scope", this.config.scopes || "openid profile");
617
649
  params.set("state", state);
650
+ params.set("nonce", nonce);
618
651
  params.set("code_challenge", challenge);
619
652
  params.set("code_challenge_method", "S256");
620
653
  return `${authorizeEndpoint}?${params.toString()}`;
@@ -656,9 +689,11 @@ var _SsoClient = class _SsoClient {
656
689
  const verifier = generateCodeVerifier();
657
690
  const challenge = await generateCodeChallenge(verifier);
658
691
  const state = generateState();
692
+ const nonce = generateState();
659
693
  const popupNonce = generateState();
660
694
  savePkceVerifier(this.config.clientId, verifier);
661
695
  saveOAuthState(state, this.config.clientId);
696
+ saveOAuthNonce(nonce, this.config.clientId);
662
697
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
663
698
  if (options.returnUrl) {
664
699
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -670,6 +705,7 @@ var _SsoClient = class _SsoClient {
670
705
  params.set("redirect_uri", this.config.redirectUri);
671
706
  params.set("scope", this.config.scopes || "openid profile");
672
707
  params.set("state", state);
708
+ params.set("nonce", nonce);
673
709
  params.set("code_challenge", challenge);
674
710
  params.set("code_challenge_method", "S256");
675
711
  params.set("popup_nonce", popupNonce);
@@ -694,6 +730,7 @@ var _SsoClient = class _SsoClient {
694
730
  if (!popup) {
695
731
  removePkceVerifier(this.config.clientId);
696
732
  removeOAuthState(this.config.clientId);
733
+ removeOAuthNonce(this.config.clientId);
697
734
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
698
735
  throw new SsoError(
699
736
  "popup_blocked",
@@ -713,6 +750,13 @@ var _SsoClient = class _SsoClient {
713
750
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
714
751
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
715
752
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
753
+ try {
754
+ event.source?.postMessage(
755
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
756
+ event.origin
757
+ );
758
+ } catch {
759
+ }
716
760
  completed = true;
717
761
  cleanup();
718
762
  if (popup && !popup.closed) {
@@ -744,6 +788,7 @@ var _SsoClient = class _SsoClient {
744
788
  } catch (err) {
745
789
  removePkceVerifier(this.config.clientId);
746
790
  removeOAuthState(this.config.clientId);
791
+ removeOAuthNonce(this.config.clientId);
747
792
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
748
793
  throw err;
749
794
  }
@@ -763,6 +808,7 @@ var _SsoClient = class _SsoClient {
763
808
  const error = params.get("error");
764
809
  if (error) {
765
810
  removeOAuthState(this.config.clientId);
811
+ removeOAuthNonce(this.config.clientId);
766
812
  removePkceVerifier(this.config.clientId);
767
813
  const desc = params.get("error_description") || error;
768
814
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
@@ -771,18 +817,21 @@ var _SsoClient = class _SsoClient {
771
817
  const returnedState = params.get("state");
772
818
  if (!code) {
773
819
  removeOAuthState(this.config.clientId);
820
+ removeOAuthNonce(this.config.clientId);
774
821
  removePkceVerifier(this.config.clientId);
775
822
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
776
823
  }
777
824
  const savedState = getOAuthState(this.config.clientId);
778
825
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
779
826
  removeOAuthState(this.config.clientId);
827
+ removeOAuthNonce(this.config.clientId);
780
828
  removePkceVerifier(this.config.clientId);
781
829
  throw new SsoError(
782
830
  "state_mismatch",
783
831
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
784
832
  );
785
833
  }
834
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
786
835
  const verifier = getPkceVerifier(this.config.clientId);
787
836
  if (!verifier) {
788
837
  throw new SsoError(
@@ -829,7 +878,8 @@ var _SsoClient = class _SsoClient {
829
878
  data.id_token,
830
879
  data.access_token,
831
880
  this.config.ssoBaseUrl,
832
- this.config.clientId
881
+ this.config.clientId,
882
+ { expectedNonce }
833
883
  );
834
884
  } catch (err) {
835
885
  removeTokenData(this.config.clientId);
@@ -837,6 +887,7 @@ var _SsoClient = class _SsoClient {
837
887
  }
838
888
  }
839
889
  removeOAuthState(this.config.clientId);
890
+ removeOAuthNonce(this.config.clientId);
840
891
  removePkceVerifier(this.config.clientId);
841
892
  const now = Date.now();
842
893
  const tokenData = {
@@ -1017,8 +1068,8 @@ var _SsoClient = class _SsoClient {
1017
1068
  clearAllSsoData(this.config.clientId);
1018
1069
  if (refreshToken && this.config.clientId) {
1019
1070
  try {
1020
- const discovery = await this._getDiscovery();
1021
- const revokeUrl = discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
1071
+ const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
1072
+ const revokeUrl = this.config.serverBaseUrl ? `${this._serverBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
1022
1073
  const revokeBody = new URLSearchParams({
1023
1074
  token: refreshToken,
1024
1075
  token_type_hint: "refresh_token",