@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.
- package/README.md +52 -9
- package/dist/index.d.mts +26 -6
- package/dist/index.d.ts +26 -6
- package/dist/index.js +118 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -10
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +81 -4
- package/dist/next/index.d.ts +81 -4
- package/dist/next/index.js +287 -23
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +285 -23
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +26 -6
- package/dist/react/index.d.ts +26 -6
- package/dist/react/index.js +176 -16
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +176 -16
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -82,8 +82,10 @@ var STORAGE_PREFIX = "nihplod_sso_";
|
|
|
82
82
|
var TOKEN_KEY = "token";
|
|
83
83
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
84
84
|
var STATE_KEY = "oauth_state";
|
|
85
|
+
var NONCE_KEY = "oidc_nonce";
|
|
85
86
|
var RETURN_URL_KEY = "return_url";
|
|
86
87
|
var LOGOUT_STATE_KEY = "logout_state";
|
|
88
|
+
var SILENT_PROBE_KEY = "silent_probe";
|
|
87
89
|
function buildKey(base, clientId) {
|
|
88
90
|
return clientId ? `${base}:${clientId}` : base;
|
|
89
91
|
}
|
|
@@ -193,6 +195,15 @@ function getOAuthState(clientId) {
|
|
|
193
195
|
function removeOAuthState(clientId) {
|
|
194
196
|
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
195
197
|
}
|
|
198
|
+
function saveOAuthNonce(nonce, clientId) {
|
|
199
|
+
_transient.set(buildKey(NONCE_KEY, clientId), nonce);
|
|
200
|
+
}
|
|
201
|
+
function getOAuthNonce(clientId) {
|
|
202
|
+
return _transient.get(buildKey(NONCE_KEY, clientId));
|
|
203
|
+
}
|
|
204
|
+
function removeOAuthNonce(clientId) {
|
|
205
|
+
_transient.remove(buildKey(NONCE_KEY, clientId));
|
|
206
|
+
}
|
|
196
207
|
function saveLogoutState(state, clientId) {
|
|
197
208
|
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
198
209
|
}
|
|
@@ -211,20 +222,33 @@ function getReturnUrl(clientId) {
|
|
|
211
222
|
function removeReturnUrl(clientId) {
|
|
212
223
|
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
213
224
|
}
|
|
225
|
+
function saveSilentProbe(state, clientId) {
|
|
226
|
+
_transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
|
|
227
|
+
}
|
|
228
|
+
function getSilentProbe(clientId) {
|
|
229
|
+
return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
|
|
230
|
+
}
|
|
231
|
+
function removeSilentProbe(clientId) {
|
|
232
|
+
_transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
|
|
233
|
+
}
|
|
214
234
|
function clearAllSsoData(clientId) {
|
|
215
235
|
if (clientId) {
|
|
216
236
|
removeTokenData(clientId);
|
|
217
237
|
removeOAuthState(clientId);
|
|
238
|
+
removeOAuthNonce(clientId);
|
|
218
239
|
removeReturnUrl(clientId);
|
|
219
240
|
removeLogoutState(clientId);
|
|
241
|
+
removeSilentProbe(clientId);
|
|
220
242
|
removePkceVerifier(clientId);
|
|
221
243
|
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
222
244
|
return;
|
|
223
245
|
}
|
|
224
246
|
removeTokenData();
|
|
225
247
|
removeOAuthState();
|
|
248
|
+
removeOAuthNonce();
|
|
226
249
|
removeReturnUrl();
|
|
227
250
|
removeLogoutState();
|
|
251
|
+
removeSilentProbe();
|
|
228
252
|
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
229
253
|
const stores = [
|
|
230
254
|
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
@@ -313,18 +337,21 @@ function normalizeIssuer(url) {
|
|
|
313
337
|
var cachedJwks = null;
|
|
314
338
|
var cachedDiscovery = null;
|
|
315
339
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
340
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
316
341
|
async function fetchDiscoveryDoc(baseUrl) {
|
|
317
342
|
const now = Date.now();
|
|
318
343
|
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
319
344
|
return cachedDiscovery.doc;
|
|
320
345
|
}
|
|
321
346
|
try {
|
|
322
|
-
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration
|
|
323
|
-
|
|
347
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
|
|
348
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
349
|
+
});
|
|
350
|
+
if (!res.ok) return null;
|
|
351
|
+
const doc = await res.json();
|
|
324
352
|
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
325
353
|
return doc;
|
|
326
354
|
} catch {
|
|
327
|
-
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
328
355
|
return null;
|
|
329
356
|
}
|
|
330
357
|
}
|
|
@@ -336,7 +363,10 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
336
363
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
337
364
|
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
338
365
|
try {
|
|
339
|
-
const res = await fetch(jwksUri,
|
|
366
|
+
const res = await fetch(jwksUri, {
|
|
367
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
368
|
+
...options.forceRefresh ? { cache: "no-cache" } : {}
|
|
369
|
+
});
|
|
340
370
|
if (!res.ok) return null;
|
|
341
371
|
const jwks = await res.json();
|
|
342
372
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -383,7 +413,7 @@ async function computeAtHash(accessToken) {
|
|
|
383
413
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
384
414
|
}
|
|
385
415
|
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
386
|
-
const { rejectHs256WhenRs256Available = true } = options;
|
|
416
|
+
const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
|
|
387
417
|
const header = decodeJwtHeader(idToken);
|
|
388
418
|
if (!header) {
|
|
389
419
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -473,10 +503,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
473
503
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
474
504
|
}
|
|
475
505
|
}
|
|
506
|
+
if (expectedNonce !== void 0) {
|
|
507
|
+
const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
|
|
508
|
+
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
509
|
+
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
476
512
|
return { sub: payload.sub };
|
|
477
513
|
}
|
|
478
514
|
|
|
479
515
|
// src/core/SsoClient.ts
|
|
516
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
517
|
+
"login_required",
|
|
518
|
+
"consent_required",
|
|
519
|
+
"interaction_required"
|
|
520
|
+
]);
|
|
480
521
|
var _SsoClient = class _SsoClient {
|
|
481
522
|
constructor(config) {
|
|
482
523
|
this._discovery = null;
|
|
@@ -570,17 +611,27 @@ var _SsoClient = class _SsoClient {
|
|
|
570
611
|
*
|
|
571
612
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
572
613
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
614
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
615
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
616
|
+
* handleCallback() 返回 null 而不抛错。
|
|
573
617
|
*
|
|
574
618
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
575
619
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
576
620
|
* 同一次登录只使用其中一个入口。
|
|
577
621
|
*/
|
|
578
|
-
async login(returnUrl) {
|
|
622
|
+
async login(returnUrl, options = {}) {
|
|
579
623
|
const verifier = generateCodeVerifier();
|
|
580
624
|
const challenge = await generateCodeChallenge(verifier);
|
|
581
625
|
const state = generateState();
|
|
626
|
+
const nonce = generateState();
|
|
582
627
|
savePkceVerifier(this.config.clientId, verifier);
|
|
583
628
|
saveOAuthState(state, this.config.clientId);
|
|
629
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
630
|
+
if (options.prompt === "none") {
|
|
631
|
+
saveSilentProbe(state, this.config.clientId);
|
|
632
|
+
} else {
|
|
633
|
+
removeSilentProbe(this.config.clientId);
|
|
634
|
+
}
|
|
584
635
|
if (returnUrl) {
|
|
585
636
|
this._saveReturnUrlIfTrusted(returnUrl);
|
|
586
637
|
}
|
|
@@ -591,8 +642,12 @@ var _SsoClient = class _SsoClient {
|
|
|
591
642
|
params.set("redirect_uri", this.config.redirectUri);
|
|
592
643
|
params.set("scope", this.config.scopes || "openid profile");
|
|
593
644
|
params.set("state", state);
|
|
645
|
+
params.set("nonce", nonce);
|
|
594
646
|
params.set("code_challenge", challenge);
|
|
595
647
|
params.set("code_challenge_method", "S256");
|
|
648
|
+
if (options.prompt) {
|
|
649
|
+
params.set("prompt", options.prompt);
|
|
650
|
+
}
|
|
596
651
|
window.location.href = `${authorizeEndpoint}?${params.toString()}`;
|
|
597
652
|
}
|
|
598
653
|
/**
|
|
@@ -604,12 +659,19 @@ var _SsoClient = class _SsoClient {
|
|
|
604
659
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
605
660
|
* 同一次登录只使用其中一个入口。
|
|
606
661
|
*/
|
|
607
|
-
async getLoginUrl(returnUrl) {
|
|
662
|
+
async getLoginUrl(returnUrl, options = {}) {
|
|
608
663
|
const verifier = generateCodeVerifier();
|
|
609
664
|
const challenge = await generateCodeChallenge(verifier);
|
|
610
665
|
const state = generateState();
|
|
666
|
+
const nonce = generateState();
|
|
611
667
|
savePkceVerifier(this.config.clientId, verifier);
|
|
612
668
|
saveOAuthState(state, this.config.clientId);
|
|
669
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
670
|
+
if (options.prompt === "none") {
|
|
671
|
+
saveSilentProbe(state, this.config.clientId);
|
|
672
|
+
} else {
|
|
673
|
+
removeSilentProbe(this.config.clientId);
|
|
674
|
+
}
|
|
613
675
|
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
614
676
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
615
677
|
const params = new URLSearchParams();
|
|
@@ -618,8 +680,12 @@ var _SsoClient = class _SsoClient {
|
|
|
618
680
|
params.set("redirect_uri", this.config.redirectUri);
|
|
619
681
|
params.set("scope", this.config.scopes || "openid profile");
|
|
620
682
|
params.set("state", state);
|
|
683
|
+
params.set("nonce", nonce);
|
|
621
684
|
params.set("code_challenge", challenge);
|
|
622
685
|
params.set("code_challenge_method", "S256");
|
|
686
|
+
if (options.prompt) {
|
|
687
|
+
params.set("prompt", options.prompt);
|
|
688
|
+
}
|
|
623
689
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
624
690
|
}
|
|
625
691
|
/**
|
|
@@ -659,9 +725,11 @@ var _SsoClient = class _SsoClient {
|
|
|
659
725
|
const verifier = generateCodeVerifier();
|
|
660
726
|
const challenge = await generateCodeChallenge(verifier);
|
|
661
727
|
const state = generateState();
|
|
728
|
+
const nonce = generateState();
|
|
662
729
|
const popupNonce = generateState();
|
|
663
730
|
savePkceVerifier(this.config.clientId, verifier);
|
|
664
731
|
saveOAuthState(state, this.config.clientId);
|
|
732
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
665
733
|
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
666
734
|
if (options.returnUrl) {
|
|
667
735
|
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
@@ -673,6 +741,7 @@ var _SsoClient = class _SsoClient {
|
|
|
673
741
|
params.set("redirect_uri", this.config.redirectUri);
|
|
674
742
|
params.set("scope", this.config.scopes || "openid profile");
|
|
675
743
|
params.set("state", state);
|
|
744
|
+
params.set("nonce", nonce);
|
|
676
745
|
params.set("code_challenge", challenge);
|
|
677
746
|
params.set("code_challenge_method", "S256");
|
|
678
747
|
params.set("popup_nonce", popupNonce);
|
|
@@ -697,6 +766,7 @@ var _SsoClient = class _SsoClient {
|
|
|
697
766
|
if (!popup) {
|
|
698
767
|
removePkceVerifier(this.config.clientId);
|
|
699
768
|
removeOAuthState(this.config.clientId);
|
|
769
|
+
removeOAuthNonce(this.config.clientId);
|
|
700
770
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
701
771
|
throw new SsoError(
|
|
702
772
|
"popup_blocked",
|
|
@@ -716,12 +786,27 @@ var _SsoClient = class _SsoClient {
|
|
|
716
786
|
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
717
787
|
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
718
788
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
789
|
+
try {
|
|
790
|
+
event.source?.postMessage(
|
|
791
|
+
{ type: "nihplod_sso_popup_ack", nonce: savedNonce },
|
|
792
|
+
event.origin
|
|
793
|
+
);
|
|
794
|
+
} catch {
|
|
795
|
+
}
|
|
719
796
|
completed = true;
|
|
720
797
|
cleanup();
|
|
721
798
|
if (popup && !popup.closed) {
|
|
722
799
|
popup.close();
|
|
723
800
|
}
|
|
724
|
-
this.handleCallback(event.data.callbackUrl).then(
|
|
801
|
+
this.handleCallback(event.data.callbackUrl).then((tokenData) => {
|
|
802
|
+
if (tokenData === null) {
|
|
803
|
+
reject(
|
|
804
|
+
new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
resolve(tokenData);
|
|
809
|
+
}).catch(reject);
|
|
725
810
|
};
|
|
726
811
|
const pollTimer = setInterval(() => {
|
|
727
812
|
if (popup.closed) {
|
|
@@ -747,6 +832,7 @@ var _SsoClient = class _SsoClient {
|
|
|
747
832
|
} catch (err) {
|
|
748
833
|
removePkceVerifier(this.config.clientId);
|
|
749
834
|
removeOAuthState(this.config.clientId);
|
|
835
|
+
removeOAuthNonce(this.config.clientId);
|
|
750
836
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
751
837
|
throw err;
|
|
752
838
|
}
|
|
@@ -758,15 +844,29 @@ var _SsoClient = class _SsoClient {
|
|
|
758
844
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
759
845
|
*
|
|
760
846
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
761
|
-
* @returns TokenData
|
|
847
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
848
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
849
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
762
850
|
*/
|
|
763
851
|
async handleCallback(callbackUrl) {
|
|
764
852
|
const url = new URL(callbackUrl);
|
|
765
853
|
const params = url.searchParams;
|
|
766
854
|
const error = params.get("error");
|
|
767
855
|
if (error) {
|
|
856
|
+
const returnedErrorState = params.get("state");
|
|
857
|
+
const probeState = getSilentProbe(this.config.clientId);
|
|
858
|
+
const savedStateForProbe = getOAuthState(this.config.clientId);
|
|
859
|
+
if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
|
|
860
|
+
removeOAuthState(this.config.clientId);
|
|
861
|
+
removeOAuthNonce(this.config.clientId);
|
|
862
|
+
removePkceVerifier(this.config.clientId);
|
|
863
|
+
removeSilentProbe(this.config.clientId);
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
768
866
|
removeOAuthState(this.config.clientId);
|
|
867
|
+
removeOAuthNonce(this.config.clientId);
|
|
769
868
|
removePkceVerifier(this.config.clientId);
|
|
869
|
+
removeSilentProbe(this.config.clientId);
|
|
770
870
|
const desc = params.get("error_description") || error;
|
|
771
871
|
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
772
872
|
}
|
|
@@ -774,18 +874,23 @@ var _SsoClient = class _SsoClient {
|
|
|
774
874
|
const returnedState = params.get("state");
|
|
775
875
|
if (!code) {
|
|
776
876
|
removeOAuthState(this.config.clientId);
|
|
877
|
+
removeOAuthNonce(this.config.clientId);
|
|
777
878
|
removePkceVerifier(this.config.clientId);
|
|
879
|
+
removeSilentProbe(this.config.clientId);
|
|
778
880
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
779
881
|
}
|
|
780
882
|
const savedState = getOAuthState(this.config.clientId);
|
|
781
883
|
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
782
884
|
removeOAuthState(this.config.clientId);
|
|
885
|
+
removeOAuthNonce(this.config.clientId);
|
|
783
886
|
removePkceVerifier(this.config.clientId);
|
|
887
|
+
removeSilentProbe(this.config.clientId);
|
|
784
888
|
throw new SsoError(
|
|
785
889
|
"state_mismatch",
|
|
786
890
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
787
891
|
);
|
|
788
892
|
}
|
|
893
|
+
const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
|
|
789
894
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
790
895
|
if (!verifier) {
|
|
791
896
|
throw new SsoError(
|
|
@@ -832,7 +937,8 @@ var _SsoClient = class _SsoClient {
|
|
|
832
937
|
data.id_token,
|
|
833
938
|
data.access_token,
|
|
834
939
|
this.config.ssoBaseUrl,
|
|
835
|
-
this.config.clientId
|
|
940
|
+
this.config.clientId,
|
|
941
|
+
{ expectedNonce }
|
|
836
942
|
);
|
|
837
943
|
} catch (err) {
|
|
838
944
|
removeTokenData(this.config.clientId);
|
|
@@ -840,7 +946,9 @@ var _SsoClient = class _SsoClient {
|
|
|
840
946
|
}
|
|
841
947
|
}
|
|
842
948
|
removeOAuthState(this.config.clientId);
|
|
949
|
+
removeOAuthNonce(this.config.clientId);
|
|
843
950
|
removePkceVerifier(this.config.clientId);
|
|
951
|
+
removeSilentProbe(this.config.clientId);
|
|
844
952
|
const now = Date.now();
|
|
845
953
|
const tokenData = {
|
|
846
954
|
access_token: data.access_token,
|