@nihplod/sso-sdk 1.3.0 → 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
 
@@ -580,8 +604,10 @@ var _SsoClient = class _SsoClient {
580
604
  const verifier = generateCodeVerifier();
581
605
  const challenge = await generateCodeChallenge(verifier);
582
606
  const state = generateState();
607
+ const nonce = generateState();
583
608
  savePkceVerifier(this.config.clientId, verifier);
584
609
  saveOAuthState(state, this.config.clientId);
610
+ saveOAuthNonce(nonce, this.config.clientId);
585
611
  if (returnUrl) {
586
612
  this._saveReturnUrlIfTrusted(returnUrl);
587
613
  }
@@ -592,6 +618,7 @@ var _SsoClient = class _SsoClient {
592
618
  params.set("redirect_uri", this.config.redirectUri);
593
619
  params.set("scope", this.config.scopes || "openid profile");
594
620
  params.set("state", state);
621
+ params.set("nonce", nonce);
595
622
  params.set("code_challenge", challenge);
596
623
  params.set("code_challenge_method", "S256");
597
624
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
@@ -609,8 +636,10 @@ var _SsoClient = class _SsoClient {
609
636
  const verifier = generateCodeVerifier();
610
637
  const challenge = await generateCodeChallenge(verifier);
611
638
  const state = generateState();
639
+ const nonce = generateState();
612
640
  savePkceVerifier(this.config.clientId, verifier);
613
641
  saveOAuthState(state, this.config.clientId);
642
+ saveOAuthNonce(nonce, this.config.clientId);
614
643
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
615
644
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
616
645
  const params = new URLSearchParams();
@@ -619,6 +648,7 @@ var _SsoClient = class _SsoClient {
619
648
  params.set("redirect_uri", this.config.redirectUri);
620
649
  params.set("scope", this.config.scopes || "openid profile");
621
650
  params.set("state", state);
651
+ params.set("nonce", nonce);
622
652
  params.set("code_challenge", challenge);
623
653
  params.set("code_challenge_method", "S256");
624
654
  return `${authorizeEndpoint}?${params.toString()}`;
@@ -660,9 +690,11 @@ var _SsoClient = class _SsoClient {
660
690
  const verifier = generateCodeVerifier();
661
691
  const challenge = await generateCodeChallenge(verifier);
662
692
  const state = generateState();
693
+ const nonce = generateState();
663
694
  const popupNonce = generateState();
664
695
  savePkceVerifier(this.config.clientId, verifier);
665
696
  saveOAuthState(state, this.config.clientId);
697
+ saveOAuthNonce(nonce, this.config.clientId);
666
698
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
667
699
  if (options.returnUrl) {
668
700
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -674,6 +706,7 @@ var _SsoClient = class _SsoClient {
674
706
  params.set("redirect_uri", this.config.redirectUri);
675
707
  params.set("scope", this.config.scopes || "openid profile");
676
708
  params.set("state", state);
709
+ params.set("nonce", nonce);
677
710
  params.set("code_challenge", challenge);
678
711
  params.set("code_challenge_method", "S256");
679
712
  params.set("popup_nonce", popupNonce);
@@ -698,6 +731,7 @@ var _SsoClient = class _SsoClient {
698
731
  if (!popup) {
699
732
  removePkceVerifier(this.config.clientId);
700
733
  removeOAuthState(this.config.clientId);
734
+ removeOAuthNonce(this.config.clientId);
701
735
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
702
736
  throw new SsoError(
703
737
  "popup_blocked",
@@ -717,6 +751,13 @@ var _SsoClient = class _SsoClient {
717
751
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
718
752
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
719
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
+ }
720
761
  completed = true;
721
762
  cleanup();
722
763
  if (popup && !popup.closed) {
@@ -748,6 +789,7 @@ var _SsoClient = class _SsoClient {
748
789
  } catch (err) {
749
790
  removePkceVerifier(this.config.clientId);
750
791
  removeOAuthState(this.config.clientId);
792
+ removeOAuthNonce(this.config.clientId);
751
793
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
752
794
  throw err;
753
795
  }
@@ -767,6 +809,7 @@ var _SsoClient = class _SsoClient {
767
809
  const error = params.get("error");
768
810
  if (error) {
769
811
  removeOAuthState(this.config.clientId);
812
+ removeOAuthNonce(this.config.clientId);
770
813
  removePkceVerifier(this.config.clientId);
771
814
  const desc = params.get("error_description") || error;
772
815
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
@@ -775,18 +818,21 @@ var _SsoClient = class _SsoClient {
775
818
  const returnedState = params.get("state");
776
819
  if (!code) {
777
820
  removeOAuthState(this.config.clientId);
821
+ removeOAuthNonce(this.config.clientId);
778
822
  removePkceVerifier(this.config.clientId);
779
823
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
780
824
  }
781
825
  const savedState = getOAuthState(this.config.clientId);
782
826
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
783
827
  removeOAuthState(this.config.clientId);
828
+ removeOAuthNonce(this.config.clientId);
784
829
  removePkceVerifier(this.config.clientId);
785
830
  throw new SsoError(
786
831
  "state_mismatch",
787
832
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
788
833
  );
789
834
  }
835
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
790
836
  const verifier = getPkceVerifier(this.config.clientId);
791
837
  if (!verifier) {
792
838
  throw new SsoError(
@@ -833,7 +879,8 @@ var _SsoClient = class _SsoClient {
833
879
  data.id_token,
834
880
  data.access_token,
835
881
  this.config.ssoBaseUrl,
836
- this.config.clientId
882
+ this.config.clientId,
883
+ { expectedNonce }
837
884
  );
838
885
  } catch (err) {
839
886
  removeTokenData(this.config.clientId);
@@ -841,6 +888,7 @@ var _SsoClient = class _SsoClient {
841
888
  }
842
889
  }
843
890
  removeOAuthState(this.config.clientId);
891
+ removeOAuthNonce(this.config.clientId);
844
892
  removePkceVerifier(this.config.clientId);
845
893
  const now = Date.now();
846
894
  const tokenData = {
@@ -1192,7 +1240,9 @@ function SsoProvider({
1192
1240
  setError(
1193
1241
  err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1194
1242
  );
1195
- removeTokenData(client.config.clientId);
1243
+ if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1244
+ removeTokenData(client.config.clientId);
1245
+ }
1196
1246
  } finally {
1197
1247
  setIsLoading(false);
1198
1248
  }
@@ -1429,6 +1479,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1429
1479
  const { client, refreshUser } = useSso();
1430
1480
  const [error, setError] = (0, import_react3.useState)(null);
1431
1481
  const [processing, setProcessing] = (0, import_react3.useState)(true);
1482
+ const [awaitingManualClose, setAwaitingManualClose] = (0, import_react3.useState)(false);
1432
1483
  const onSuccessRef = (0, import_react3.useRef)(onSuccess);
1433
1484
  const onErrorRef = (0, import_react3.useRef)(onError);
1434
1485
  (0, import_react3.useEffect)(() => {
@@ -1443,12 +1494,38 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1443
1494
  targetOrigin = window.opener.location.origin;
1444
1495
  } catch {
1445
1496
  }
1446
- window.opener.postMessage(
1447
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1448
- targetOrigin
1449
- );
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);
1450
1523
  Promise.resolve().then(() => setProcessing(false));
1451
- return;
1524
+ return () => {
1525
+ clearInterval(resendTimer);
1526
+ clearTimeout(ackTimeout);
1527
+ window.removeEventListener("message", handleAck);
1528
+ };
1452
1529
  }
1453
1530
  let cancelled = false;
1454
1531
  async function handleCallback() {
@@ -1487,6 +1564,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1487
1564
  if (renderError) return import_react3.default.createElement(import_react3.default.Fragment, null, renderError(error));
1488
1565
  return import_react3.default.createElement(DefaultCallbackError, { error });
1489
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
+ }
1490
1582
  if (processing) {
1491
1583
  return import_react3.default.createElement(
1492
1584
  "div",