@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.
@@ -83,6 +83,7 @@ var STORAGE_PREFIX = "nihplod_sso_";
83
83
  var TOKEN_KEY = "token";
84
84
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
85
85
  var STATE_KEY = "oauth_state";
86
+ var NONCE_KEY = "oidc_nonce";
86
87
  var RETURN_URL_KEY = "return_url";
87
88
  var LOGOUT_STATE_KEY = "logout_state";
88
89
  function buildKey(base, clientId) {
@@ -165,6 +166,15 @@ function getOAuthState(clientId) {
165
166
  function removeOAuthState(clientId) {
166
167
  _transient.remove(buildKey(STATE_KEY, clientId));
167
168
  }
169
+ function saveOAuthNonce(nonce, clientId) {
170
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
171
+ }
172
+ function getOAuthNonce(clientId) {
173
+ return _transient.get(buildKey(NONCE_KEY, clientId));
174
+ }
175
+ function removeOAuthNonce(clientId) {
176
+ _transient.remove(buildKey(NONCE_KEY, clientId));
177
+ }
168
178
  function saveLogoutState(state, clientId) {
169
179
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
170
180
  }
@@ -187,6 +197,7 @@ function clearAllSsoData(clientId) {
187
197
  if (clientId) {
188
198
  removeTokenData(clientId);
189
199
  removeOAuthState(clientId);
200
+ removeOAuthNonce(clientId);
190
201
  removeReturnUrl(clientId);
191
202
  removeLogoutState(clientId);
192
203
  removePkceVerifier(clientId);
@@ -195,6 +206,7 @@ function clearAllSsoData(clientId) {
195
206
  }
196
207
  removeTokenData();
197
208
  removeOAuthState();
209
+ removeOAuthNonce();
198
210
  removeReturnUrl();
199
211
  removeLogoutState();
200
212
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
@@ -280,18 +292,21 @@ function normalizeIssuer(url) {
280
292
  var cachedJwks = null;
281
293
  var cachedDiscovery = null;
282
294
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
295
+ var FETCH_TIMEOUT_MS = 1e4;
283
296
  async function fetchDiscoveryDoc(baseUrl) {
284
297
  const now = Date.now();
285
298
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
286
299
  return cachedDiscovery.doc;
287
300
  }
288
301
  try {
289
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
290
- const doc = res.ok ? await res.json() : null;
302
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
303
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
304
+ });
305
+ if (!res.ok) return null;
306
+ const doc = await res.json();
291
307
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
292
308
  return doc;
293
309
  } catch {
294
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
295
310
  return null;
296
311
  }
297
312
  }
@@ -303,7 +318,10 @@ async function fetchJwks(baseUrl, options = {}) {
303
318
  const discovery = await fetchDiscoveryDoc(baseUrl);
304
319
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
305
320
  try {
306
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
321
+ const res = await fetch(jwksUri, {
322
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
323
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
324
+ });
307
325
  if (!res.ok) return null;
308
326
  const jwks = await res.json();
309
327
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -350,7 +368,7 @@ async function computeAtHash(accessToken) {
350
368
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
351
369
  }
352
370
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
353
- const { rejectHs256WhenRs256Available = true } = options;
371
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
354
372
  const header = decodeJwtHeader(idToken);
355
373
  if (!header) {
356
374
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -440,6 +458,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
440
458
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
441
459
  }
442
460
  }
461
+ if (expectedNonce !== void 0) {
462
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
463
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
464
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
465
+ }
466
+ }
443
467
  return { sub: payload.sub };
444
468
  }
445
469
 
@@ -454,6 +478,7 @@ var _SsoClient = class _SsoClient {
454
478
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
455
479
  const base = config.ssoBaseUrl.replace(/\/+$/, "");
456
480
  this.config = { ...config, ssoBaseUrl: base };
481
+ this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
457
482
  }
458
483
  // ============================================
459
484
  // 内部方法
@@ -469,7 +494,7 @@ var _SsoClient = class _SsoClient {
469
494
  if (this._discovery && now - this._discoveryFetchedAt < _SsoClient.DISCOVERY_TTL_MS) {
470
495
  return this._discovery;
471
496
  }
472
- const url = `${this.config.ssoBaseUrl}/api/oauth/.well-known/openid-configuration`;
497
+ const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
473
498
  try {
474
499
  const controller = new AbortController();
475
500
  const timeoutId = setTimeout(
@@ -500,14 +525,16 @@ var _SsoClient = class _SsoClient {
500
525
  if (d) return d.authorization_endpoint;
501
526
  return `${this.config.ssoBaseUrl}/api/oauth/authorize`;
502
527
  }
503
- /** 获取 token 端点 URL(优先 Discovery,回退默认) */
528
+ /** 获取 token 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
504
529
  async _getTokenEndpoint() {
530
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/token`;
505
531
  const d = await this._getDiscovery();
506
532
  if (d) return d.token_endpoint;
507
533
  return `${this.config.ssoBaseUrl}/api/oauth/token`;
508
534
  }
509
- /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
535
+ /** 获取 userinfo 端点 URL(服务器间调用:serverBaseUrl 直连优先,其次 Discovery,回退默认) */
510
536
  async _getUserinfoEndpoint() {
537
+ if (this.config.serverBaseUrl) return `${this._serverBase}/api/oauth/userinfo`;
511
538
  const d = await this._getDiscovery();
512
539
  if (d) return d.userinfo_endpoint;
513
540
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
@@ -543,8 +570,10 @@ var _SsoClient = class _SsoClient {
543
570
  const verifier = generateCodeVerifier();
544
571
  const challenge = await generateCodeChallenge(verifier);
545
572
  const state = generateState();
573
+ const nonce = generateState();
546
574
  savePkceVerifier(this.config.clientId, verifier);
547
575
  saveOAuthState(state, this.config.clientId);
576
+ saveOAuthNonce(nonce, this.config.clientId);
548
577
  if (returnUrl) {
549
578
  this._saveReturnUrlIfTrusted(returnUrl);
550
579
  }
@@ -555,6 +584,7 @@ var _SsoClient = class _SsoClient {
555
584
  params.set("redirect_uri", this.config.redirectUri);
556
585
  params.set("scope", this.config.scopes || "openid profile");
557
586
  params.set("state", state);
587
+ params.set("nonce", nonce);
558
588
  params.set("code_challenge", challenge);
559
589
  params.set("code_challenge_method", "S256");
560
590
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
@@ -572,8 +602,10 @@ var _SsoClient = class _SsoClient {
572
602
  const verifier = generateCodeVerifier();
573
603
  const challenge = await generateCodeChallenge(verifier);
574
604
  const state = generateState();
605
+ const nonce = generateState();
575
606
  savePkceVerifier(this.config.clientId, verifier);
576
607
  saveOAuthState(state, this.config.clientId);
608
+ saveOAuthNonce(nonce, this.config.clientId);
577
609
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
578
610
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
579
611
  const params = new URLSearchParams();
@@ -582,6 +614,7 @@ var _SsoClient = class _SsoClient {
582
614
  params.set("redirect_uri", this.config.redirectUri);
583
615
  params.set("scope", this.config.scopes || "openid profile");
584
616
  params.set("state", state);
617
+ params.set("nonce", nonce);
585
618
  params.set("code_challenge", challenge);
586
619
  params.set("code_challenge_method", "S256");
587
620
  return `${authorizeEndpoint}?${params.toString()}`;
@@ -623,9 +656,11 @@ var _SsoClient = class _SsoClient {
623
656
  const verifier = generateCodeVerifier();
624
657
  const challenge = await generateCodeChallenge(verifier);
625
658
  const state = generateState();
659
+ const nonce = generateState();
626
660
  const popupNonce = generateState();
627
661
  savePkceVerifier(this.config.clientId, verifier);
628
662
  saveOAuthState(state, this.config.clientId);
663
+ saveOAuthNonce(nonce, this.config.clientId);
629
664
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
630
665
  if (options.returnUrl) {
631
666
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -637,6 +672,7 @@ var _SsoClient = class _SsoClient {
637
672
  params.set("redirect_uri", this.config.redirectUri);
638
673
  params.set("scope", this.config.scopes || "openid profile");
639
674
  params.set("state", state);
675
+ params.set("nonce", nonce);
640
676
  params.set("code_challenge", challenge);
641
677
  params.set("code_challenge_method", "S256");
642
678
  params.set("popup_nonce", popupNonce);
@@ -661,6 +697,7 @@ var _SsoClient = class _SsoClient {
661
697
  if (!popup) {
662
698
  removePkceVerifier(this.config.clientId);
663
699
  removeOAuthState(this.config.clientId);
700
+ removeOAuthNonce(this.config.clientId);
664
701
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
665
702
  throw new SsoError(
666
703
  "popup_blocked",
@@ -680,6 +717,13 @@ var _SsoClient = class _SsoClient {
680
717
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
681
718
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
682
719
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
720
+ try {
721
+ event.source?.postMessage(
722
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
723
+ event.origin
724
+ );
725
+ } catch {
726
+ }
683
727
  completed = true;
684
728
  cleanup();
685
729
  if (popup && !popup.closed) {
@@ -711,6 +755,7 @@ var _SsoClient = class _SsoClient {
711
755
  } catch (err) {
712
756
  removePkceVerifier(this.config.clientId);
713
757
  removeOAuthState(this.config.clientId);
758
+ removeOAuthNonce(this.config.clientId);
714
759
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
715
760
  throw err;
716
761
  }
@@ -730,6 +775,7 @@ var _SsoClient = class _SsoClient {
730
775
  const error = params.get("error");
731
776
  if (error) {
732
777
  removeOAuthState(this.config.clientId);
778
+ removeOAuthNonce(this.config.clientId);
733
779
  removePkceVerifier(this.config.clientId);
734
780
  const desc = params.get("error_description") || error;
735
781
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
@@ -738,18 +784,21 @@ var _SsoClient = class _SsoClient {
738
784
  const returnedState = params.get("state");
739
785
  if (!code) {
740
786
  removeOAuthState(this.config.clientId);
787
+ removeOAuthNonce(this.config.clientId);
741
788
  removePkceVerifier(this.config.clientId);
742
789
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
743
790
  }
744
791
  const savedState = getOAuthState(this.config.clientId);
745
792
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
746
793
  removeOAuthState(this.config.clientId);
794
+ removeOAuthNonce(this.config.clientId);
747
795
  removePkceVerifier(this.config.clientId);
748
796
  throw new SsoError(
749
797
  "state_mismatch",
750
798
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
751
799
  );
752
800
  }
801
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
753
802
  const verifier = getPkceVerifier(this.config.clientId);
754
803
  if (!verifier) {
755
804
  throw new SsoError(
@@ -796,7 +845,8 @@ var _SsoClient = class _SsoClient {
796
845
  data.id_token,
797
846
  data.access_token,
798
847
  this.config.ssoBaseUrl,
799
- this.config.clientId
848
+ this.config.clientId,
849
+ { expectedNonce }
800
850
  );
801
851
  } catch (err) {
802
852
  removeTokenData(this.config.clientId);
@@ -804,6 +854,7 @@ var _SsoClient = class _SsoClient {
804
854
  }
805
855
  }
806
856
  removeOAuthState(this.config.clientId);
857
+ removeOAuthNonce(this.config.clientId);
807
858
  removePkceVerifier(this.config.clientId);
808
859
  const now = Date.now();
809
860
  const tokenData = {
@@ -984,8 +1035,8 @@ var _SsoClient = class _SsoClient {
984
1035
  clearAllSsoData(this.config.clientId);
985
1036
  if (refreshToken && this.config.clientId) {
986
1037
  try {
987
- const discovery = await this._getDiscovery();
988
- const revokeUrl = discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
1038
+ const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
1039
+ const revokeUrl = this.config.serverBaseUrl ? `${this._serverBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${this.config.ssoBaseUrl}/api/oauth/revoke`;
989
1040
  const revokeBody = new URLSearchParams({
990
1041
  token: refreshToken,
991
1042
  token_type_hint: "refresh_token",
@@ -1155,7 +1206,9 @@ function SsoProvider({
1155
1206
  setError(
1156
1207
  err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1157
1208
  );
1158
- removeTokenData(client.config.clientId);
1209
+ if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1210
+ removeTokenData(client.config.clientId);
1211
+ }
1159
1212
  } finally {
1160
1213
  setIsLoading(false);
1161
1214
  }
@@ -1392,6 +1445,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1392
1445
  const { client, refreshUser } = useSso();
1393
1446
  const [error, setError] = useState3(null);
1394
1447
  const [processing, setProcessing] = useState3(true);
1448
+ const [awaitingManualClose, setAwaitingManualClose] = useState3(false);
1395
1449
  const onSuccessRef = useRef3(onSuccess);
1396
1450
  const onErrorRef = useRef3(onError);
1397
1451
  useEffect3(() => {
@@ -1406,12 +1460,38 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1406
1460
  targetOrigin = window.opener.location.origin;
1407
1461
  } catch {
1408
1462
  }
1409
- window.opener.postMessage(
1410
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1411
- targetOrigin
1412
- );
1463
+ const message = {
1464
+ type: "nihplod_sso_popup_callback",
1465
+ callbackUrl: window.location.href,
1466
+ nonce: nonce || void 0
1467
+ };
1468
+ let acked = false;
1469
+ const send = () => {
1470
+ if (acked || !window.opener || window.opener.closed) return;
1471
+ window.opener.postMessage(message, targetOrigin);
1472
+ };
1473
+ send();
1474
+ const resendTimer = setInterval(send, 500);
1475
+ const ackTimeout = setTimeout(() => {
1476
+ if (acked) return;
1477
+ clearInterval(resendTimer);
1478
+ setAwaitingManualClose(true);
1479
+ }, 1e4);
1480
+ const handleAck = (event) => {
1481
+ if (event.origin !== targetOrigin) return;
1482
+ if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1483
+ if (nonce && event.data.nonce !== nonce) return;
1484
+ acked = true;
1485
+ clearInterval(resendTimer);
1486
+ clearTimeout(ackTimeout);
1487
+ };
1488
+ window.addEventListener("message", handleAck);
1413
1489
  Promise.resolve().then(() => setProcessing(false));
1414
- return;
1490
+ return () => {
1491
+ clearInterval(resendTimer);
1492
+ clearTimeout(ackTimeout);
1493
+ window.removeEventListener("message", handleAck);
1494
+ };
1415
1495
  }
1416
1496
  let cancelled = false;
1417
1497
  async function handleCallback() {
@@ -1450,6 +1530,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1450
1530
  if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
1451
1531
  return React3.createElement(DefaultCallbackError, { error });
1452
1532
  }
1533
+ if (awaitingManualClose) {
1534
+ return React3.createElement(
1535
+ "div",
1536
+ {
1537
+ style: {
1538
+ display: "flex",
1539
+ alignItems: "center",
1540
+ justifyContent: "center",
1541
+ minHeight: "100vh",
1542
+ fontFamily: "system-ui, sans-serif"
1543
+ }
1544
+ },
1545
+ React3.createElement("p", null, "\u767B\u5F55\u5DF2\u5B8C\u6210\uFF0C\u8BF7\u624B\u52A8\u5173\u95ED\u6B64\u7A97\u53E3")
1546
+ );
1547
+ }
1453
1548
  if (processing) {
1454
1549
  return React3.createElement(
1455
1550
  "div",