@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.
@@ -117,6 +117,7 @@ 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";
122
123
  function buildKey(base, clientId) {
@@ -199,6 +200,15 @@ function getOAuthState(clientId) {
199
200
  function removeOAuthState(clientId) {
200
201
  _transient.remove(buildKey(STATE_KEY, clientId));
201
202
  }
203
+ function saveOAuthNonce(nonce, clientId) {
204
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
205
+ }
206
+ function getOAuthNonce(clientId) {
207
+ return _transient.get(buildKey(NONCE_KEY, clientId));
208
+ }
209
+ function removeOAuthNonce(clientId) {
210
+ _transient.remove(buildKey(NONCE_KEY, clientId));
211
+ }
202
212
  function saveLogoutState(state, clientId) {
203
213
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
204
214
  }
@@ -221,6 +231,7 @@ function clearAllSsoData(clientId) {
221
231
  if (clientId) {
222
232
  removeTokenData(clientId);
223
233
  removeOAuthState(clientId);
234
+ removeOAuthNonce(clientId);
224
235
  removeReturnUrl(clientId);
225
236
  removeLogoutState(clientId);
226
237
  removePkceVerifier(clientId);
@@ -229,6 +240,7 @@ function clearAllSsoData(clientId) {
229
240
  }
230
241
  removeTokenData();
231
242
  removeOAuthState();
243
+ removeOAuthNonce();
232
244
  removeReturnUrl();
233
245
  removeLogoutState();
234
246
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
@@ -314,18 +326,21 @@ function normalizeIssuer(url) {
314
326
  var cachedJwks = null;
315
327
  var cachedDiscovery = null;
316
328
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
329
+ var FETCH_TIMEOUT_MS = 1e4;
317
330
  async function fetchDiscoveryDoc(baseUrl) {
318
331
  const now = Date.now();
319
332
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
320
333
  return cachedDiscovery.doc;
321
334
  }
322
335
  try {
323
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
324
- const doc = res.ok ? await res.json() : null;
336
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
337
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
338
+ });
339
+ if (!res.ok) return null;
340
+ const doc = await res.json();
325
341
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
326
342
  return doc;
327
343
  } catch {
328
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
329
344
  return null;
330
345
  }
331
346
  }
@@ -337,7 +352,10 @@ async function fetchJwks(baseUrl, options = {}) {
337
352
  const discovery = await fetchDiscoveryDoc(baseUrl);
338
353
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
339
354
  try {
340
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
355
+ const res = await fetch(jwksUri, {
356
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
357
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
358
+ });
341
359
  if (!res.ok) return null;
342
360
  const jwks = await res.json();
343
361
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -384,7 +402,7 @@ async function computeAtHash(accessToken) {
384
402
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
385
403
  }
386
404
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
387
- const { rejectHs256WhenRs256Available = true } = options;
405
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
388
406
  const header = decodeJwtHeader(idToken);
389
407
  if (!header) {
390
408
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -474,6 +492,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
474
492
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
475
493
  }
476
494
  }
495
+ if (expectedNonce !== void 0) {
496
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
497
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
498
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
499
+ }
500
+ }
477
501
  return { sub: payload.sub };
478
502
  }
479
503
 
@@ -488,6 +512,7 @@ var _SsoClient = class _SsoClient {
488
512
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
489
513
  const base = config.ssoBaseUrl.replace(/\/+$/, "");
490
514
  this.config = { ...config, ssoBaseUrl: base };
515
+ this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
491
516
  }
492
517
  // ============================================
493
518
  // 内部方法
@@ -503,7 +528,7 @@ var _SsoClient = class _SsoClient {
503
528
  if (this._discovery && now - this._discoveryFetchedAt < _SsoClient.DISCOVERY_TTL_MS) {
504
529
  return this._discovery;
505
530
  }
506
- const url = `${this.config.ssoBaseUrl}/api/oauth/.well-known/openid-configuration`;
531
+ const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
507
532
  try {
508
533
  const controller = new AbortController();
509
534
  const timeoutId = setTimeout(
@@ -534,14 +559,16 @@ var _SsoClient = class _SsoClient {
534
559
  if (d) return d.authorization_endpoint;
535
560
  return `${this.config.ssoBaseUrl}/api/oauth/authorize`;
536
561
  }
537
- /** 获取 token 端点 URL(优先 Discovery,回退默认) */
562
+ /** 获取 token 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
538
563
  async _getTokenEndpoint() {
564
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/token`;
539
565
  const d = await this._getDiscovery();
540
566
  if (d) return d.token_endpoint;
541
567
  return `${this.config.ssoBaseUrl}/api/oauth/token`;
542
568
  }
543
- /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
569
+ /** 获取 userinfo 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
544
570
  async _getUserinfoEndpoint() {
571
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/userinfo`;
545
572
  const d = await this._getDiscovery();
546
573
  if (d) return d.userinfo_endpoint;
547
574
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
@@ -577,8 +604,10 @@ var _SsoClient = class _SsoClient {
577
604
  const verifier = generateCodeVerifier();
578
605
  const challenge = await generateCodeChallenge(verifier);
579
606
  const state = generateState();
607
+ const nonce = generateState();
580
608
  savePkceVerifier(this.config.clientId, verifier);
581
609
  saveOAuthState(state, this.config.clientId);
610
+ saveOAuthNonce(nonce, this.config.clientId);
582
611
  if (returnUrl) {
583
612
  this._saveReturnUrlIfTrusted(returnUrl);
584
613
  }
@@ -589,6 +618,7 @@ var _SsoClient = class _SsoClient {
589
618
  params.set("redirect_uri", this.config.redirectUri);
590
619
  params.set("scope", this.config.scopes || "openid profile");
591
620
  params.set("state", state);
621
+ params.set("nonce", nonce);
592
622
  params.set("code_challenge", challenge);
593
623
  params.set("code_challenge_method", "S256");
594
624
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
@@ -606,8 +636,10 @@ var _SsoClient = class _SsoClient {
606
636
  const verifier = generateCodeVerifier();
607
637
  const challenge = await generateCodeChallenge(verifier);
608
638
  const state = generateState();
639
+ const nonce = generateState();
609
640
  savePkceVerifier(this.config.clientId, verifier);
610
641
  saveOAuthState(state, this.config.clientId);
642
+ saveOAuthNonce(nonce, this.config.clientId);
611
643
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
612
644
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
613
645
  const params = new URLSearchParams();
@@ -616,6 +648,7 @@ var _SsoClient = class _SsoClient {
616
648
  params.set("redirect_uri", this.config.redirectUri);
617
649
  params.set("scope", this.config.scopes || "openid profile");
618
650
  params.set("state", state);
651
+ params.set("nonce", nonce);
619
652
  params.set("code_challenge", challenge);
620
653
  params.set("code_challenge_method", "S256");
621
654
  return `${authorizeEndpoint}?${params.toString()}`;
@@ -657,9 +690,11 @@ var _SsoClient = class _SsoClient {
657
690
  const verifier = generateCodeVerifier();
658
691
  const challenge = await generateCodeChallenge(verifier);
659
692
  const state = generateState();
693
+ const nonce = generateState();
660
694
  const popupNonce = generateState();
661
695
  savePkceVerifier(this.config.clientId, verifier);
662
696
  saveOAuthState(state, this.config.clientId);
697
+ saveOAuthNonce(nonce, this.config.clientId);
663
698
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
664
699
  if (options.returnUrl) {
665
700
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -671,6 +706,7 @@ var _SsoClient = class _SsoClient {
671
706
  params.set("redirect_uri", this.config.redirectUri);
672
707
  params.set("scope", this.config.scopes || "openid profile");
673
708
  params.set("state", state);
709
+ params.set("nonce", nonce);
674
710
  params.set("code_challenge", challenge);
675
711
  params.set("code_challenge_method", "S256");
676
712
  params.set("popup_nonce", popupNonce);
@@ -695,6 +731,7 @@ var _SsoClient = class _SsoClient {
695
731
  if (!popup) {
696
732
  removePkceVerifier(this.config.clientId);
697
733
  removeOAuthState(this.config.clientId);
734
+ removeOAuthNonce(this.config.clientId);
698
735
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
699
736
  throw new SsoError(
700
737
  "popup_blocked",
@@ -714,6 +751,13 @@ var _SsoClient = class _SsoClient {
714
751
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
715
752
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
716
753
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
754
+ try {
755
+ event.source?.postMessage(
756
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
757
+ event.origin
758
+ );
759
+ } catch {
760
+ }
717
761
  completed = true;
718
762
  cleanup();
719
763
  if (popup && !popup.closed) {
@@ -745,6 +789,7 @@ var _SsoClient = class _SsoClient {
745
789
  } catch (err) {
746
790
  removePkceVerifier(this.config.clientId);
747
791
  removeOAuthState(this.config.clientId);
792
+ removeOAuthNonce(this.config.clientId);
748
793
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
749
794
  throw err;
750
795
  }
@@ -764,6 +809,7 @@ var _SsoClient = class _SsoClient {
764
809
  const error = params.get("error");
765
810
  if (error) {
766
811
  removeOAuthState(this.config.clientId);
812
+ removeOAuthNonce(this.config.clientId);
767
813
  removePkceVerifier(this.config.clientId);
768
814
  const desc = params.get("error_description") || error;
769
815
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
@@ -772,18 +818,21 @@ var _SsoClient = class _SsoClient {
772
818
  const returnedState = params.get("state");
773
819
  if (!code) {
774
820
  removeOAuthState(this.config.clientId);
821
+ removeOAuthNonce(this.config.clientId);
775
822
  removePkceVerifier(this.config.clientId);
776
823
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
777
824
  }
778
825
  const savedState = getOAuthState(this.config.clientId);
779
826
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
780
827
  removeOAuthState(this.config.clientId);
828
+ removeOAuthNonce(this.config.clientId);
781
829
  removePkceVerifier(this.config.clientId);
782
830
  throw new SsoError(
783
831
  "state_mismatch",
784
832
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
785
833
  );
786
834
  }
835
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
787
836
  const verifier = getPkceVerifier(this.config.clientId);
788
837
  if (!verifier) {
789
838
  throw new SsoError(
@@ -830,7 +879,8 @@ var _SsoClient = class _SsoClient {
830
879
  data.id_token,
831
880
  data.access_token,
832
881
  this.config.ssoBaseUrl,
833
- this.config.clientId
882
+ this.config.clientId,
883
+ { expectedNonce }
834
884
  );
835
885
  } catch (err) {
836
886
  removeTokenData(this.config.clientId);
@@ -838,6 +888,7 @@ var _SsoClient = class _SsoClient {
838
888
  }
839
889
  }
840
890
  removeOAuthState(this.config.clientId);
891
+ removeOAuthNonce(this.config.clientId);
841
892
  removePkceVerifier(this.config.clientId);
842
893
  const now = Date.now();
843
894
  const tokenData = {
@@ -1018,8 +1069,8 @@ var _SsoClient = class _SsoClient {
1018
1069
  clearAllSsoData(this.config.clientId);
1019
1070
  if (refreshToken && this.config.clientId) {
1020
1071
  try {
1021
- const discovery = await this._getDiscovery();
1022
- const revokeUrl = discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
1072
+ const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
1073
+ const revokeUrl = this.config.serverBaseUrl ? `${this._serverBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
1023
1074
  const revokeBody = new URLSearchParams({
1024
1075
  token: refreshToken,
1025
1076
  token_type_hint: "refresh_token",
@@ -1189,7 +1240,9 @@ function SsoProvider({
1189
1240
  setError(
1190
1241
  err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1191
1242
  );
1192
- removeTokenData(client.config.clientId);
1243
+ if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1244
+ removeTokenData(client.config.clientId);
1245
+ }
1193
1246
  } finally {
1194
1247
  setIsLoading(false);
1195
1248
  }
@@ -1426,6 +1479,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1426
1479
  const { client, refreshUser } = useSso();
1427
1480
  const [error, setError] = (0, import_react3.useState)(null);
1428
1481
  const [processing, setProcessing] = (0, import_react3.useState)(true);
1482
+ const [awaitingManualClose, setAwaitingManualClose] = (0, import_react3.useState)(false);
1429
1483
  const onSuccessRef = (0, import_react3.useRef)(onSuccess);
1430
1484
  const onErrorRef = (0, import_react3.useRef)(onError);
1431
1485
  (0, import_react3.useEffect)(() => {
@@ -1440,12 +1494,38 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1440
1494
  targetOrigin = window.opener.location.origin;
1441
1495
  } catch {
1442
1496
  }
1443
- window.opener.postMessage(
1444
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1445
- targetOrigin
1446
- );
1497
+ const message = {
1498
+ type: "nihplod_sso_popup_callback",
1499
+ callbackUrl: window.location.href,
1500
+ nonce: nonce || void 0
1501
+ };
1502
+ let acked = false;
1503
+ const send = () => {
1504
+ if (acked || !window.opener || window.opener.closed) return;
1505
+ window.opener.postMessage(message, targetOrigin);
1506
+ };
1507
+ send();
1508
+ const resendTimer = setInterval(send, 500);
1509
+ const ackTimeout = setTimeout(() => {
1510
+ if (acked) return;
1511
+ clearInterval(resendTimer);
1512
+ setAwaitingManualClose(true);
1513
+ }, 1e4);
1514
+ const handleAck = (event) => {
1515
+ if (event.origin !== targetOrigin) return;
1516
+ if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1517
+ if (nonce && event.data.nonce !== nonce) return;
1518
+ acked = true;
1519
+ clearInterval(resendTimer);
1520
+ clearTimeout(ackTimeout);
1521
+ };
1522
+ window.addEventListener("message", handleAck);
1447
1523
  Promise.resolve().then(() => setProcessing(false));
1448
- return;
1524
+ return () => {
1525
+ clearInterval(resendTimer);
1526
+ clearTimeout(ackTimeout);
1527
+ window.removeEventListener("message", handleAck);
1528
+ };
1449
1529
  }
1450
1530
  let cancelled = false;
1451
1531
  async function handleCallback() {
@@ -1484,6 +1564,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1484
1564
  if (renderError) return import_react3.default.createElement(import_react3.default.Fragment, null, renderError(error));
1485
1565
  return import_react3.default.createElement(DefaultCallbackError, { error });
1486
1566
  }
1567
+ if (awaitingManualClose) {
1568
+ return import_react3.default.createElement(
1569
+ "div",
1570
+ {
1571
+ style: {
1572
+ display: "flex",
1573
+ alignItems: "center",
1574
+ justifyContent: "center",
1575
+ minHeight: "100vh",
1576
+ fontFamily: "system-ui, sans-serif"
1577
+ }
1578
+ },
1579
+ import_react3.default.createElement("p", null, "\u767B\u5F55\u5DF2\u5B8C\u6210\uFF0C\u8BF7\u624B\u52A8\u5173\u95ED\u6B64\u7A97\u53E3")
1580
+ );
1581
+ }
1487
1582
  if (processing) {
1488
1583
  return import_react3.default.createElement(
1489
1584
  "div",