@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.
@@ -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
 
@@ -546,8 +570,10 @@ var _SsoClient = class _SsoClient {
546
570
  const verifier = generateCodeVerifier();
547
571
  const challenge = await generateCodeChallenge(verifier);
548
572
  const state = generateState();
573
+ const nonce = generateState();
549
574
  savePkceVerifier(this.config.clientId, verifier);
550
575
  saveOAuthState(state, this.config.clientId);
576
+ saveOAuthNonce(nonce, this.config.clientId);
551
577
  if (returnUrl) {
552
578
  this._saveReturnUrlIfTrusted(returnUrl);
553
579
  }
@@ -558,6 +584,7 @@ var _SsoClient = class _SsoClient {
558
584
  params.set("redirect_uri", this.config.redirectUri);
559
585
  params.set("scope", this.config.scopes || "openid profile");
560
586
  params.set("state", state);
587
+ params.set("nonce", nonce);
561
588
  params.set("code_challenge", challenge);
562
589
  params.set("code_challenge_method", "S256");
563
590
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
@@ -575,8 +602,10 @@ var _SsoClient = class _SsoClient {
575
602
  const verifier = generateCodeVerifier();
576
603
  const challenge = await generateCodeChallenge(verifier);
577
604
  const state = generateState();
605
+ const nonce = generateState();
578
606
  savePkceVerifier(this.config.clientId, verifier);
579
607
  saveOAuthState(state, this.config.clientId);
608
+ saveOAuthNonce(nonce, this.config.clientId);
580
609
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
581
610
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
582
611
  const params = new URLSearchParams();
@@ -585,6 +614,7 @@ var _SsoClient = class _SsoClient {
585
614
  params.set("redirect_uri", this.config.redirectUri);
586
615
  params.set("scope", this.config.scopes || "openid profile");
587
616
  params.set("state", state);
617
+ params.set("nonce", nonce);
588
618
  params.set("code_challenge", challenge);
589
619
  params.set("code_challenge_method", "S256");
590
620
  return `${authorizeEndpoint}?${params.toString()}`;
@@ -626,9 +656,11 @@ var _SsoClient = class _SsoClient {
626
656
  const verifier = generateCodeVerifier();
627
657
  const challenge = await generateCodeChallenge(verifier);
628
658
  const state = generateState();
659
+ const nonce = generateState();
629
660
  const popupNonce = generateState();
630
661
  savePkceVerifier(this.config.clientId, verifier);
631
662
  saveOAuthState(state, this.config.clientId);
663
+ saveOAuthNonce(nonce, this.config.clientId);
632
664
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
633
665
  if (options.returnUrl) {
634
666
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -640,6 +672,7 @@ var _SsoClient = class _SsoClient {
640
672
  params.set("redirect_uri", this.config.redirectUri);
641
673
  params.set("scope", this.config.scopes || "openid profile");
642
674
  params.set("state", state);
675
+ params.set("nonce", nonce);
643
676
  params.set("code_challenge", challenge);
644
677
  params.set("code_challenge_method", "S256");
645
678
  params.set("popup_nonce", popupNonce);
@@ -664,6 +697,7 @@ var _SsoClient = class _SsoClient {
664
697
  if (!popup) {
665
698
  removePkceVerifier(this.config.clientId);
666
699
  removeOAuthState(this.config.clientId);
700
+ removeOAuthNonce(this.config.clientId);
667
701
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
668
702
  throw new SsoError(
669
703
  "popup_blocked",
@@ -683,6 +717,13 @@ var _SsoClient = class _SsoClient {
683
717
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
684
718
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
685
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
+ }
686
727
  completed = true;
687
728
  cleanup();
688
729
  if (popup && !popup.closed) {
@@ -714,6 +755,7 @@ var _SsoClient = class _SsoClient {
714
755
  } catch (err) {
715
756
  removePkceVerifier(this.config.clientId);
716
757
  removeOAuthState(this.config.clientId);
758
+ removeOAuthNonce(this.config.clientId);
717
759
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
718
760
  throw err;
719
761
  }
@@ -733,6 +775,7 @@ var _SsoClient = class _SsoClient {
733
775
  const error = params.get("error");
734
776
  if (error) {
735
777
  removeOAuthState(this.config.clientId);
778
+ removeOAuthNonce(this.config.clientId);
736
779
  removePkceVerifier(this.config.clientId);
737
780
  const desc = params.get("error_description") || error;
738
781
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
@@ -741,18 +784,21 @@ var _SsoClient = class _SsoClient {
741
784
  const returnedState = params.get("state");
742
785
  if (!code) {
743
786
  removeOAuthState(this.config.clientId);
787
+ removeOAuthNonce(this.config.clientId);
744
788
  removePkceVerifier(this.config.clientId);
745
789
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
746
790
  }
747
791
  const savedState = getOAuthState(this.config.clientId);
748
792
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
749
793
  removeOAuthState(this.config.clientId);
794
+ removeOAuthNonce(this.config.clientId);
750
795
  removePkceVerifier(this.config.clientId);
751
796
  throw new SsoError(
752
797
  "state_mismatch",
753
798
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
754
799
  );
755
800
  }
801
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
756
802
  const verifier = getPkceVerifier(this.config.clientId);
757
803
  if (!verifier) {
758
804
  throw new SsoError(
@@ -799,7 +845,8 @@ var _SsoClient = class _SsoClient {
799
845
  data.id_token,
800
846
  data.access_token,
801
847
  this.config.ssoBaseUrl,
802
- this.config.clientId
848
+ this.config.clientId,
849
+ { expectedNonce }
803
850
  );
804
851
  } catch (err) {
805
852
  removeTokenData(this.config.clientId);
@@ -807,6 +854,7 @@ var _SsoClient = class _SsoClient {
807
854
  }
808
855
  }
809
856
  removeOAuthState(this.config.clientId);
857
+ removeOAuthNonce(this.config.clientId);
810
858
  removePkceVerifier(this.config.clientId);
811
859
  const now = Date.now();
812
860
  const tokenData = {
@@ -1158,7 +1206,9 @@ function SsoProvider({
1158
1206
  setError(
1159
1207
  err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1160
1208
  );
1161
- removeTokenData(client.config.clientId);
1209
+ if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1210
+ removeTokenData(client.config.clientId);
1211
+ }
1162
1212
  } finally {
1163
1213
  setIsLoading(false);
1164
1214
  }
@@ -1395,6 +1445,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1395
1445
  const { client, refreshUser } = useSso();
1396
1446
  const [error, setError] = useState3(null);
1397
1447
  const [processing, setProcessing] = useState3(true);
1448
+ const [awaitingManualClose, setAwaitingManualClose] = useState3(false);
1398
1449
  const onSuccessRef = useRef3(onSuccess);
1399
1450
  const onErrorRef = useRef3(onError);
1400
1451
  useEffect3(() => {
@@ -1409,12 +1460,38 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1409
1460
  targetOrigin = window.opener.location.origin;
1410
1461
  } catch {
1411
1462
  }
1412
- window.opener.postMessage(
1413
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1414
- targetOrigin
1415
- );
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);
1416
1489
  Promise.resolve().then(() => setProcessing(false));
1417
- return;
1490
+ return () => {
1491
+ clearInterval(resendTimer);
1492
+ clearTimeout(ackTimeout);
1493
+ window.removeEventListener("message", handleAck);
1494
+ };
1418
1495
  }
1419
1496
  let cancelled = false;
1420
1497
  async function handleCallback() {
@@ -1453,6 +1530,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1453
1530
  if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
1454
1531
  return React3.createElement(DefaultCallbackError, { error });
1455
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
+ }
1456
1548
  if (processing) {
1457
1549
  return React3.createElement(
1458
1550
  "div",