@nihplod/sso-sdk 1.3.0 → 1.4.0

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,8 +83,10 @@ 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";
89
+ var SILENT_PROBE_KEY = "silent_probe";
88
90
  function buildKey(base, clientId) {
89
91
  return clientId ? `${base}:${clientId}` : base;
90
92
  }
@@ -165,6 +167,15 @@ function getOAuthState(clientId) {
165
167
  function removeOAuthState(clientId) {
166
168
  _transient.remove(buildKey(STATE_KEY, clientId));
167
169
  }
170
+ function saveOAuthNonce(nonce, clientId) {
171
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
172
+ }
173
+ function getOAuthNonce(clientId) {
174
+ return _transient.get(buildKey(NONCE_KEY, clientId));
175
+ }
176
+ function removeOAuthNonce(clientId) {
177
+ _transient.remove(buildKey(NONCE_KEY, clientId));
178
+ }
168
179
  function saveLogoutState(state, clientId) {
169
180
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
170
181
  }
@@ -183,20 +194,33 @@ function getReturnUrl(clientId) {
183
194
  function removeReturnUrl(clientId) {
184
195
  _transient.remove(buildKey(RETURN_URL_KEY, clientId));
185
196
  }
197
+ function saveSilentProbe(state, clientId) {
198
+ _transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
199
+ }
200
+ function getSilentProbe(clientId) {
201
+ return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
202
+ }
203
+ function removeSilentProbe(clientId) {
204
+ _transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
205
+ }
186
206
  function clearAllSsoData(clientId) {
187
207
  if (clientId) {
188
208
  removeTokenData(clientId);
189
209
  removeOAuthState(clientId);
210
+ removeOAuthNonce(clientId);
190
211
  removeReturnUrl(clientId);
191
212
  removeLogoutState(clientId);
213
+ removeSilentProbe(clientId);
192
214
  removePkceVerifier(clientId);
193
215
  removePkceVerifier(`${clientId}_popup_nonce`);
194
216
  return;
195
217
  }
196
218
  removeTokenData();
197
219
  removeOAuthState();
220
+ removeOAuthNonce();
198
221
  removeReturnUrl();
199
222
  removeLogoutState();
223
+ removeSilentProbe();
200
224
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
201
225
  const stores = [
202
226
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -280,18 +304,21 @@ function normalizeIssuer(url) {
280
304
  var cachedJwks = null;
281
305
  var cachedDiscovery = null;
282
306
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
307
+ var FETCH_TIMEOUT_MS = 1e4;
283
308
  async function fetchDiscoveryDoc(baseUrl) {
284
309
  const now = Date.now();
285
310
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
286
311
  return cachedDiscovery.doc;
287
312
  }
288
313
  try {
289
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
290
- const doc = res.ok ? await res.json() : null;
314
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
315
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
316
+ });
317
+ if (!res.ok) return null;
318
+ const doc = await res.json();
291
319
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
292
320
  return doc;
293
321
  } catch {
294
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
295
322
  return null;
296
323
  }
297
324
  }
@@ -303,7 +330,10 @@ async function fetchJwks(baseUrl, options = {}) {
303
330
  const discovery = await fetchDiscoveryDoc(baseUrl);
304
331
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
305
332
  try {
306
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
333
+ const res = await fetch(jwksUri, {
334
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
335
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
336
+ });
307
337
  if (!res.ok) return null;
308
338
  const jwks = await res.json();
309
339
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -350,7 +380,7 @@ async function computeAtHash(accessToken) {
350
380
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
351
381
  }
352
382
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
353
- const { rejectHs256WhenRs256Available = true } = options;
383
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
354
384
  const header = decodeJwtHeader(idToken);
355
385
  if (!header) {
356
386
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -440,10 +470,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
440
470
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
441
471
  }
442
472
  }
473
+ if (expectedNonce !== void 0) {
474
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
475
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
476
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
477
+ }
478
+ }
443
479
  return { sub: payload.sub };
444
480
  }
445
481
 
446
482
  // src/core/SsoClient.ts
483
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
484
+ "login_required",
485
+ "consent_required",
486
+ "interaction_required"
487
+ ]);
447
488
  var _SsoClient = class _SsoClient {
448
489
  constructor(config) {
449
490
  this._discovery = null;
@@ -537,17 +578,27 @@ var _SsoClient = class _SsoClient {
537
578
  *
538
579
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
539
580
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
581
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
582
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
583
+ * handleCallback() 返回 null 而不抛错。
540
584
  *
541
585
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
542
586
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
543
587
  * 同一次登录只使用其中一个入口。
544
588
  */
545
- async login(returnUrl) {
589
+ async login(returnUrl, options = {}) {
546
590
  const verifier = generateCodeVerifier();
547
591
  const challenge = await generateCodeChallenge(verifier);
548
592
  const state = generateState();
593
+ const nonce = generateState();
549
594
  savePkceVerifier(this.config.clientId, verifier);
550
595
  saveOAuthState(state, this.config.clientId);
596
+ saveOAuthNonce(nonce, this.config.clientId);
597
+ if (options.prompt === "none") {
598
+ saveSilentProbe(state, this.config.clientId);
599
+ } else {
600
+ removeSilentProbe(this.config.clientId);
601
+ }
551
602
  if (returnUrl) {
552
603
  this._saveReturnUrlIfTrusted(returnUrl);
553
604
  }
@@ -558,8 +609,12 @@ var _SsoClient = class _SsoClient {
558
609
  params.set("redirect_uri", this.config.redirectUri);
559
610
  params.set("scope", this.config.scopes || "openid profile");
560
611
  params.set("state", state);
612
+ params.set("nonce", nonce);
561
613
  params.set("code_challenge", challenge);
562
614
  params.set("code_challenge_method", "S256");
615
+ if (options.prompt) {
616
+ params.set("prompt", options.prompt);
617
+ }
563
618
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
564
619
  }
565
620
  /**
@@ -571,12 +626,19 @@ var _SsoClient = class _SsoClient {
571
626
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
572
627
  * 同一次登录只使用其中一个入口。
573
628
  */
574
- async getLoginUrl(returnUrl) {
629
+ async getLoginUrl(returnUrl, options = {}) {
575
630
  const verifier = generateCodeVerifier();
576
631
  const challenge = await generateCodeChallenge(verifier);
577
632
  const state = generateState();
633
+ const nonce = generateState();
578
634
  savePkceVerifier(this.config.clientId, verifier);
579
635
  saveOAuthState(state, this.config.clientId);
636
+ saveOAuthNonce(nonce, this.config.clientId);
637
+ if (options.prompt === "none") {
638
+ saveSilentProbe(state, this.config.clientId);
639
+ } else {
640
+ removeSilentProbe(this.config.clientId);
641
+ }
580
642
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
581
643
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
582
644
  const params = new URLSearchParams();
@@ -585,8 +647,12 @@ var _SsoClient = class _SsoClient {
585
647
  params.set("redirect_uri", this.config.redirectUri);
586
648
  params.set("scope", this.config.scopes || "openid profile");
587
649
  params.set("state", state);
650
+ params.set("nonce", nonce);
588
651
  params.set("code_challenge", challenge);
589
652
  params.set("code_challenge_method", "S256");
653
+ if (options.prompt) {
654
+ params.set("prompt", options.prompt);
655
+ }
590
656
  return `${authorizeEndpoint}?${params.toString()}`;
591
657
  }
592
658
  /**
@@ -626,9 +692,11 @@ var _SsoClient = class _SsoClient {
626
692
  const verifier = generateCodeVerifier();
627
693
  const challenge = await generateCodeChallenge(verifier);
628
694
  const state = generateState();
695
+ const nonce = generateState();
629
696
  const popupNonce = generateState();
630
697
  savePkceVerifier(this.config.clientId, verifier);
631
698
  saveOAuthState(state, this.config.clientId);
699
+ saveOAuthNonce(nonce, this.config.clientId);
632
700
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
633
701
  if (options.returnUrl) {
634
702
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -640,6 +708,7 @@ var _SsoClient = class _SsoClient {
640
708
  params.set("redirect_uri", this.config.redirectUri);
641
709
  params.set("scope", this.config.scopes || "openid profile");
642
710
  params.set("state", state);
711
+ params.set("nonce", nonce);
643
712
  params.set("code_challenge", challenge);
644
713
  params.set("code_challenge_method", "S256");
645
714
  params.set("popup_nonce", popupNonce);
@@ -664,6 +733,7 @@ var _SsoClient = class _SsoClient {
664
733
  if (!popup) {
665
734
  removePkceVerifier(this.config.clientId);
666
735
  removeOAuthState(this.config.clientId);
736
+ removeOAuthNonce(this.config.clientId);
667
737
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
668
738
  throw new SsoError(
669
739
  "popup_blocked",
@@ -683,12 +753,27 @@ var _SsoClient = class _SsoClient {
683
753
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
684
754
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
685
755
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
756
+ try {
757
+ event.source?.postMessage(
758
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
759
+ event.origin
760
+ );
761
+ } catch {
762
+ }
686
763
  completed = true;
687
764
  cleanup();
688
765
  if (popup && !popup.closed) {
689
766
  popup.close();
690
767
  }
691
- this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
768
+ this.handleCallback(event.data.callbackUrl).then((tokenData) => {
769
+ if (tokenData === null) {
770
+ reject(
771
+ new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
772
+ );
773
+ return;
774
+ }
775
+ resolve(tokenData);
776
+ }).catch(reject);
692
777
  };
693
778
  const pollTimer = setInterval(() => {
694
779
  if (popup.closed) {
@@ -714,6 +799,7 @@ var _SsoClient = class _SsoClient {
714
799
  } catch (err) {
715
800
  removePkceVerifier(this.config.clientId);
716
801
  removeOAuthState(this.config.clientId);
802
+ removeOAuthNonce(this.config.clientId);
717
803
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
718
804
  throw err;
719
805
  }
@@ -725,15 +811,29 @@ var _SsoClient = class _SsoClient {
725
811
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
726
812
  *
727
813
  * @param callbackUrl - 完整的回调 URL(window.location.href)
728
- * @returns TokenData 或 null
814
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
815
+ * (IdP 回跳 login_required / consent_required / interaction_required
816
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
729
817
  */
730
818
  async handleCallback(callbackUrl) {
731
819
  const url = new URL(callbackUrl);
732
820
  const params = url.searchParams;
733
821
  const error = params.get("error");
734
822
  if (error) {
823
+ const returnedErrorState = params.get("state");
824
+ const probeState = getSilentProbe(this.config.clientId);
825
+ const savedStateForProbe = getOAuthState(this.config.clientId);
826
+ if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
827
+ removeOAuthState(this.config.clientId);
828
+ removeOAuthNonce(this.config.clientId);
829
+ removePkceVerifier(this.config.clientId);
830
+ removeSilentProbe(this.config.clientId);
831
+ return null;
832
+ }
735
833
  removeOAuthState(this.config.clientId);
834
+ removeOAuthNonce(this.config.clientId);
736
835
  removePkceVerifier(this.config.clientId);
836
+ removeSilentProbe(this.config.clientId);
737
837
  const desc = params.get("error_description") || error;
738
838
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
739
839
  }
@@ -741,18 +841,23 @@ var _SsoClient = class _SsoClient {
741
841
  const returnedState = params.get("state");
742
842
  if (!code) {
743
843
  removeOAuthState(this.config.clientId);
844
+ removeOAuthNonce(this.config.clientId);
744
845
  removePkceVerifier(this.config.clientId);
846
+ removeSilentProbe(this.config.clientId);
745
847
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
746
848
  }
747
849
  const savedState = getOAuthState(this.config.clientId);
748
850
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
749
851
  removeOAuthState(this.config.clientId);
852
+ removeOAuthNonce(this.config.clientId);
750
853
  removePkceVerifier(this.config.clientId);
854
+ removeSilentProbe(this.config.clientId);
751
855
  throw new SsoError(
752
856
  "state_mismatch",
753
857
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
754
858
  );
755
859
  }
860
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
756
861
  const verifier = getPkceVerifier(this.config.clientId);
757
862
  if (!verifier) {
758
863
  throw new SsoError(
@@ -799,7 +904,8 @@ var _SsoClient = class _SsoClient {
799
904
  data.id_token,
800
905
  data.access_token,
801
906
  this.config.ssoBaseUrl,
802
- this.config.clientId
907
+ this.config.clientId,
908
+ { expectedNonce }
803
909
  );
804
910
  } catch (err) {
805
911
  removeTokenData(this.config.clientId);
@@ -807,7 +913,9 @@ var _SsoClient = class _SsoClient {
807
913
  }
808
914
  }
809
915
  removeOAuthState(this.config.clientId);
916
+ removeOAuthNonce(this.config.clientId);
810
917
  removePkceVerifier(this.config.clientId);
918
+ removeSilentProbe(this.config.clientId);
811
919
  const now = Date.now();
812
920
  const tokenData = {
813
921
  access_token: data.access_token,
@@ -1158,7 +1266,9 @@ function SsoProvider({
1158
1266
  setError(
1159
1267
  err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1160
1268
  );
1161
- removeTokenData(client.config.clientId);
1269
+ if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
1270
+ removeTokenData(client.config.clientId);
1271
+ }
1162
1272
  } finally {
1163
1273
  setIsLoading(false);
1164
1274
  }
@@ -1395,6 +1505,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1395
1505
  const { client, refreshUser } = useSso();
1396
1506
  const [error, setError] = useState3(null);
1397
1507
  const [processing, setProcessing] = useState3(true);
1508
+ const [awaitingManualClose, setAwaitingManualClose] = useState3(false);
1398
1509
  const onSuccessRef = useRef3(onSuccess);
1399
1510
  const onErrorRef = useRef3(onError);
1400
1511
  useEffect3(() => {
@@ -1409,18 +1520,52 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1409
1520
  targetOrigin = window.opener.location.origin;
1410
1521
  } catch {
1411
1522
  }
1412
- window.opener.postMessage(
1413
- { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1414
- targetOrigin
1415
- );
1523
+ const message = {
1524
+ type: "nihplod_sso_popup_callback",
1525
+ callbackUrl: window.location.href,
1526
+ nonce: nonce || void 0
1527
+ };
1528
+ let acked = false;
1529
+ const send = () => {
1530
+ if (acked || !window.opener || window.opener.closed) return;
1531
+ window.opener.postMessage(message, targetOrigin);
1532
+ };
1533
+ send();
1534
+ const resendTimer = setInterval(send, 500);
1535
+ const ackTimeout = setTimeout(() => {
1536
+ if (acked) return;
1537
+ clearInterval(resendTimer);
1538
+ setAwaitingManualClose(true);
1539
+ }, 1e4);
1540
+ const handleAck = (event) => {
1541
+ if (event.origin !== targetOrigin) return;
1542
+ if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
1543
+ if (nonce && event.data.nonce !== nonce) return;
1544
+ acked = true;
1545
+ clearInterval(resendTimer);
1546
+ clearTimeout(ackTimeout);
1547
+ };
1548
+ window.addEventListener("message", handleAck);
1416
1549
  Promise.resolve().then(() => setProcessing(false));
1417
- return;
1550
+ return () => {
1551
+ clearInterval(resendTimer);
1552
+ clearTimeout(ackTimeout);
1553
+ window.removeEventListener("message", handleAck);
1554
+ };
1418
1555
  }
1419
1556
  let cancelled = false;
1420
1557
  async function handleCallback() {
1421
1558
  try {
1422
1559
  const tokenData = await client.handleCallback(window.location.href);
1423
1560
  if (cancelled) return;
1561
+ if (tokenData === null) {
1562
+ const clientId2 = client.config.clientId;
1563
+ const probeReturnUrl = getReturnUrl(clientId2);
1564
+ removeReturnUrl(clientId2);
1565
+ const target = probeReturnUrl && isTrustedReturnUrl(probeReturnUrl, window.location.origin) ? probeReturnUrl : "/";
1566
+ window.location.href = target + (target.includes("?") ? "&" : "?") + "sso_probe=no_session";
1567
+ return;
1568
+ }
1424
1569
  await refreshUser();
1425
1570
  if (cancelled) return;
1426
1571
  const clientId = client.config.clientId;
@@ -1453,6 +1598,21 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1453
1598
  if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
1454
1599
  return React3.createElement(DefaultCallbackError, { error });
1455
1600
  }
1601
+ if (awaitingManualClose) {
1602
+ return React3.createElement(
1603
+ "div",
1604
+ {
1605
+ style: {
1606
+ display: "flex",
1607
+ alignItems: "center",
1608
+ justifyContent: "center",
1609
+ minHeight: "100vh",
1610
+ fontFamily: "system-ui, sans-serif"
1611
+ }
1612
+ },
1613
+ React3.createElement("p", null, "\u767B\u5F55\u5DF2\u5B8C\u6210\uFF0C\u8BF7\u624B\u52A8\u5173\u95ED\u6B64\u7A97\u53E3")
1614
+ );
1615
+ }
1456
1616
  if (processing) {
1457
1617
  return React3.createElement(
1458
1618
  "div",