@nihplod/sso-sdk 1.5.0 → 1.7.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 +21 -2
- package/dist/index.d.mts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +148 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -84
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +87 -2
- package/dist/next/index.d.ts +87 -2
- package/dist/next/index.js +330 -64
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +329 -64
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +32 -2
- package/dist/react/index.d.ts +32 -2
- package/dist/react/index.js +196 -105
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +196 -105
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.js
CHANGED
|
@@ -124,24 +124,6 @@ var SILENT_PROBE_KEY = "silent_probe";
|
|
|
124
124
|
function buildKey(base, clientId) {
|
|
125
125
|
return clientId ? `${base}:${clientId}` : base;
|
|
126
126
|
}
|
|
127
|
-
function createMemoryStorageAdapter() {
|
|
128
|
-
const store = /* @__PURE__ */ new Map();
|
|
129
|
-
return {
|
|
130
|
-
get(key) {
|
|
131
|
-
return store.get(key) ?? null;
|
|
132
|
-
},
|
|
133
|
-
set(key, value) {
|
|
134
|
-
store.set(key, value);
|
|
135
|
-
},
|
|
136
|
-
remove(key) {
|
|
137
|
-
store.delete(key);
|
|
138
|
-
},
|
|
139
|
-
keys() {
|
|
140
|
-
return [...store.keys()];
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
145
127
|
function createSessionStorageAdapter() {
|
|
146
128
|
const fallback = /* @__PURE__ */ new Map();
|
|
147
129
|
return {
|
|
@@ -409,7 +391,19 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
409
391
|
return cachedJwks.jwks;
|
|
410
392
|
}
|
|
411
393
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
412
|
-
|
|
394
|
+
let jwksUri = `${baseUrl}/api/oauth/jwks`;
|
|
395
|
+
if (discovery?.jwks_uri) {
|
|
396
|
+
try {
|
|
397
|
+
if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
|
|
398
|
+
jwksUri = discovery.jwks_uri;
|
|
399
|
+
} else {
|
|
400
|
+
console.warn(
|
|
401
|
+
"[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
413
407
|
try {
|
|
414
408
|
const res = await fetch(jwksUri, {
|
|
415
409
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -539,7 +533,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
539
533
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
540
534
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
541
535
|
}
|
|
542
|
-
if (typeof payload.iat
|
|
536
|
+
if (typeof payload.iat !== "number") {
|
|
537
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
538
|
+
}
|
|
539
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
543
540
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
544
541
|
}
|
|
545
542
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -556,13 +553,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
556
553
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
557
554
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
558
555
|
}
|
|
556
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
557
|
+
throw new SsoError(
|
|
558
|
+
"id_token_nonce_mismatch",
|
|
559
|
+
"ID Token \u643A\u5E26 nonce \u4F46\u672C\u5730\u65E0\u671F\u671B\u503C\uFF08\u53EF\u80FD\u975E\u672C\u6B21\u767B\u5F55\u4F1A\u8BDD\u7B7E\u53D1\uFF09"
|
|
560
|
+
);
|
|
559
561
|
}
|
|
560
562
|
return { sub: payload.sub };
|
|
561
563
|
}
|
|
562
564
|
|
|
563
|
-
// src/core/
|
|
565
|
+
// src/core/http.ts
|
|
564
566
|
var REQUEST_TIMEOUT_MS = 1e4;
|
|
565
|
-
var REVOKE_TIMEOUT_MS = 3e3;
|
|
566
567
|
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
567
568
|
const controller = new AbortController();
|
|
568
569
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -572,6 +573,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
|
|
|
572
573
|
clearTimeout(timer);
|
|
573
574
|
}
|
|
574
575
|
}
|
|
576
|
+
|
|
577
|
+
// src/core/SsoClient.ts
|
|
578
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
579
|
+
var EXPIRY_SKEW_MS = 1e4;
|
|
575
580
|
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
576
581
|
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
577
582
|
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
@@ -589,16 +594,14 @@ function assertValidTokenResponse(data, requireRefreshToken) {
|
|
|
589
594
|
);
|
|
590
595
|
}
|
|
591
596
|
}
|
|
592
|
-
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
593
|
-
"login_required",
|
|
594
|
-
"consent_required",
|
|
595
|
-
"interaction_required"
|
|
596
|
-
]);
|
|
597
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set(["login_required", "consent_required", "interaction_required"]);
|
|
597
598
|
var _SsoClient = class _SsoClient {
|
|
598
599
|
constructor(config) {
|
|
599
600
|
this._discovery = null;
|
|
600
601
|
this._discoveryFetchedAt = 0;
|
|
601
602
|
this._refreshLock = null;
|
|
603
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
604
|
+
this._discoveryOriginWarned = false;
|
|
602
605
|
if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
|
|
603
606
|
if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
|
|
604
607
|
if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
|
|
@@ -622,6 +625,68 @@ var _SsoClient = class _SsoClient {
|
|
|
622
625
|
// ============================================
|
|
623
626
|
// 内部方法
|
|
624
627
|
// ============================================
|
|
628
|
+
/**
|
|
629
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
630
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
631
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
632
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
633
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
634
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
635
|
+
*/
|
|
636
|
+
_sanitizeDiscovery(d) {
|
|
637
|
+
if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
|
|
638
|
+
const allowedOrigins = /* @__PURE__ */ new Set();
|
|
639
|
+
try {
|
|
640
|
+
allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
|
|
641
|
+
allowedOrigins.add(new URL(this._serverBase).origin);
|
|
642
|
+
} catch {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
const sameOrigin = (endpoint) => {
|
|
646
|
+
try {
|
|
647
|
+
return allowedOrigins.has(new URL(endpoint).origin);
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
|
|
653
|
+
let authorizationOk = false;
|
|
654
|
+
try {
|
|
655
|
+
authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
|
|
656
|
+
} catch {
|
|
657
|
+
authorizationOk = false;
|
|
658
|
+
}
|
|
659
|
+
const bad = [];
|
|
660
|
+
if (!authorizationOk) bad.push("authorization_endpoint");
|
|
661
|
+
if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
|
|
662
|
+
if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
|
|
663
|
+
if (d.jwks_uri && !sameOrigin(d.jwks_uri)) bad.push("jwks_uri");
|
|
664
|
+
if (d.revocation_endpoint && !sameOrigin(d.revocation_endpoint)) {
|
|
665
|
+
bad.push("revocation_endpoint");
|
|
666
|
+
}
|
|
667
|
+
if (d.introspection_endpoint && !sameOrigin(d.introspection_endpoint)) {
|
|
668
|
+
bad.push("introspection_endpoint");
|
|
669
|
+
}
|
|
670
|
+
if (d.end_session_endpoint) {
|
|
671
|
+
try {
|
|
672
|
+
if (new URL(d.end_session_endpoint).origin !== publicOrigin) {
|
|
673
|
+
bad.push("end_session_endpoint");
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
bad.push("end_session_endpoint");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (bad.length > 0) {
|
|
680
|
+
if (!this._discoveryOriginWarned) {
|
|
681
|
+
this._discoveryOriginWarned = true;
|
|
682
|
+
console.warn(
|
|
683
|
+
`[SSO SDK] OIDC Discovery \u7AEF\u70B9 origin \u4E0E ssoBaseUrl/serverBaseUrl \u4E0D\u5339\u914D\uFF08${bad.join(", ")}\uFF09\uFF0C\u5DF2\u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9\u3002\u82E5 SSO \u4E2D\u5FC3\u786E\u5B9E\u6258\u7BA1\u8DE8\u6E90\u7AEF\u70B9\uFF0C\u8BF7\u8BBE\u7F6E allowCrossOriginDiscoveryEndpoints: true`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
return d;
|
|
689
|
+
}
|
|
625
690
|
/**
|
|
626
691
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
627
692
|
*
|
|
@@ -636,19 +701,17 @@ var _SsoClient = class _SsoClient {
|
|
|
636
701
|
const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
|
|
637
702
|
try {
|
|
638
703
|
const controller = new AbortController();
|
|
639
|
-
const timeoutId = setTimeout(
|
|
640
|
-
() => controller.abort(),
|
|
641
|
-
_SsoClient.DISCOVERY_TIMEOUT_MS
|
|
642
|
-
);
|
|
704
|
+
const timeoutId = setTimeout(() => controller.abort(), _SsoClient.DISCOVERY_TIMEOUT_MS);
|
|
643
705
|
const res = await fetch(url, { signal: controller.signal });
|
|
644
706
|
clearTimeout(timeoutId);
|
|
645
707
|
if (!res.ok) {
|
|
646
|
-
console.warn(
|
|
647
|
-
`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
|
|
648
|
-
);
|
|
708
|
+
console.warn(`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`);
|
|
649
709
|
return null;
|
|
650
710
|
}
|
|
651
|
-
|
|
711
|
+
const doc = await res.json();
|
|
712
|
+
const sanitized = this._sanitizeDiscovery(doc);
|
|
713
|
+
if (!sanitized) return null;
|
|
714
|
+
this._discovery = sanitized;
|
|
652
715
|
this._discoveryFetchedAt = now;
|
|
653
716
|
return this._discovery;
|
|
654
717
|
} catch (err) {
|
|
@@ -857,10 +920,7 @@ var _SsoClient = class _SsoClient {
|
|
|
857
920
|
removeOAuthState(this.config.clientId);
|
|
858
921
|
removeOAuthNonce(this.config.clientId);
|
|
859
922
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
860
|
-
throw new SsoError(
|
|
861
|
-
"popup_blocked",
|
|
862
|
-
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
863
|
-
);
|
|
923
|
+
throw new SsoError("popup_blocked", "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5");
|
|
864
924
|
}
|
|
865
925
|
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
866
926
|
try {
|
|
@@ -889,9 +949,7 @@ var _SsoClient = class _SsoClient {
|
|
|
889
949
|
}
|
|
890
950
|
this.handleCallback(event.data.callbackUrl).then((tokenData) => {
|
|
891
951
|
if (tokenData === null) {
|
|
892
|
-
reject(
|
|
893
|
-
new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
|
|
894
|
-
);
|
|
952
|
+
reject(new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09"));
|
|
895
953
|
return;
|
|
896
954
|
}
|
|
897
955
|
resolve(tokenData);
|
|
@@ -902,12 +960,7 @@ var _SsoClient = class _SsoClient {
|
|
|
902
960
|
if (!completed) {
|
|
903
961
|
completed = true;
|
|
904
962
|
cleanup();
|
|
905
|
-
reject(
|
|
906
|
-
new SsoError(
|
|
907
|
-
"popup_closed",
|
|
908
|
-
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
909
|
-
)
|
|
910
|
-
);
|
|
963
|
+
reject(new SsoError("popup_closed", "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"));
|
|
911
964
|
}
|
|
912
965
|
}
|
|
913
966
|
}, 500);
|
|
@@ -974,18 +1027,24 @@ var _SsoClient = class _SsoClient {
|
|
|
974
1027
|
removeOAuthNonce(this.config.clientId);
|
|
975
1028
|
removePkceVerifier(this.config.clientId);
|
|
976
1029
|
removeSilentProbe(this.config.clientId);
|
|
977
|
-
throw new SsoError(
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1030
|
+
throw new SsoError("state_mismatch", "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB");
|
|
1031
|
+
}
|
|
1032
|
+
const returnedIss = params.get("iss");
|
|
1033
|
+
if (returnedIss) {
|
|
1034
|
+
const discovery = await this._getDiscovery();
|
|
1035
|
+
const expectedIssuer = (discovery?.issuer || this.config.ssoBaseUrl).replace(/\/+$/, "");
|
|
1036
|
+
if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
|
|
1037
|
+
removeOAuthState(this.config.clientId);
|
|
1038
|
+
removeOAuthNonce(this.config.clientId);
|
|
1039
|
+
removePkceVerifier(this.config.clientId);
|
|
1040
|
+
removeSilentProbe(this.config.clientId);
|
|
1041
|
+
throw new SsoError("issuer_mismatch", "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 IdP \u6DF7\u53E0\u653B\u51FB");
|
|
1042
|
+
}
|
|
981
1043
|
}
|
|
982
1044
|
const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
|
|
983
1045
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
984
1046
|
if (!verifier) {
|
|
985
|
-
throw new SsoError(
|
|
986
|
-
"pkce_required",
|
|
987
|
-
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
988
|
-
);
|
|
1047
|
+
throw new SsoError("pkce_required", "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09");
|
|
989
1048
|
}
|
|
990
1049
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
991
1050
|
const body = new URLSearchParams();
|
|
@@ -1008,6 +1067,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1008
1067
|
throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
|
|
1009
1068
|
}
|
|
1010
1069
|
if (!res.ok) {
|
|
1070
|
+
removeOAuthState(this.config.clientId);
|
|
1071
|
+
removeOAuthNonce(this.config.clientId);
|
|
1072
|
+
removePkceVerifier(this.config.clientId);
|
|
1073
|
+
removeSilentProbe(this.config.clientId);
|
|
1011
1074
|
let errData = {};
|
|
1012
1075
|
try {
|
|
1013
1076
|
errData = await res.json();
|
|
@@ -1023,11 +1086,12 @@ var _SsoClient = class _SsoClient {
|
|
|
1023
1086
|
assertValidTokenResponse(data, true);
|
|
1024
1087
|
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
1025
1088
|
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1089
|
+
removeOAuthState(this.config.clientId);
|
|
1090
|
+
removeOAuthNonce(this.config.clientId);
|
|
1091
|
+
removePkceVerifier(this.config.clientId);
|
|
1092
|
+
removeSilentProbe(this.config.clientId);
|
|
1026
1093
|
removeTokenData(this.config.clientId);
|
|
1027
|
-
throw new SsoError(
|
|
1028
|
-
"id_token_invalid",
|
|
1029
|
-
"Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
|
|
1030
|
-
);
|
|
1094
|
+
throw new SsoError("id_token_invalid", "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09");
|
|
1031
1095
|
}
|
|
1032
1096
|
if (data.id_token) {
|
|
1033
1097
|
try {
|
|
@@ -1092,19 +1156,14 @@ var _SsoClient = class _SsoClient {
|
|
|
1092
1156
|
}
|
|
1093
1157
|
let res = null;
|
|
1094
1158
|
let lastErr;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
break;
|
|
1104
|
-
} catch (err) {
|
|
1105
|
-
lastErr = err;
|
|
1106
|
-
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
1107
|
-
}
|
|
1159
|
+
try {
|
|
1160
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1163
|
+
body: body.toString()
|
|
1164
|
+
});
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
lastErr = err;
|
|
1108
1167
|
}
|
|
1109
1168
|
if (lastErr || !res) {
|
|
1110
1169
|
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
@@ -1167,7 +1226,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1167
1226
|
if (!tokenData) {
|
|
1168
1227
|
throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
|
|
1169
1228
|
}
|
|
1170
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1229
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1171
1230
|
tokenData = await this.refreshToken();
|
|
1172
1231
|
}
|
|
1173
1232
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
@@ -1186,10 +1245,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1186
1245
|
removeTokenData(this.config.clientId);
|
|
1187
1246
|
throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
|
|
1188
1247
|
}
|
|
1189
|
-
throw new SsoError(
|
|
1190
|
-
"userinfo_failed",
|
|
1191
|
-
`\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
|
|
1192
|
-
);
|
|
1248
|
+
throw new SsoError("userinfo_failed", `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`);
|
|
1193
1249
|
}
|
|
1194
1250
|
return await res.json();
|
|
1195
1251
|
}
|
|
@@ -1202,7 +1258,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1202
1258
|
async getAccessToken() {
|
|
1203
1259
|
let tokenData = getTokenData(this.config.clientId);
|
|
1204
1260
|
if (!tokenData) return null;
|
|
1205
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1261
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1206
1262
|
tokenData = await this.refreshToken();
|
|
1207
1263
|
}
|
|
1208
1264
|
return tokenData.access_token;
|
|
@@ -1211,6 +1267,9 @@ var _SsoClient = class _SsoClient {
|
|
|
1211
1267
|
* 检查是否已认证(不发起网络请求)
|
|
1212
1268
|
*
|
|
1213
1269
|
* 仅检查本地是否存在未过期的 access_token。
|
|
1270
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
1271
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
1272
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
1214
1273
|
*/
|
|
1215
1274
|
isAuthenticated() {
|
|
1216
1275
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -1246,7 +1305,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1246
1305
|
if (this.config.clientSecret) {
|
|
1247
1306
|
revokeBody.set("client_secret", this.config.clientSecret);
|
|
1248
1307
|
}
|
|
1249
|
-
|
|
1308
|
+
const doRevoke = () => fetchWithTimeout(
|
|
1250
1309
|
revokeUrl,
|
|
1251
1310
|
{
|
|
1252
1311
|
method: "POST",
|
|
@@ -1255,6 +1314,14 @@ var _SsoClient = class _SsoClient {
|
|
|
1255
1314
|
},
|
|
1256
1315
|
REVOKE_TIMEOUT_MS
|
|
1257
1316
|
);
|
|
1317
|
+
let res = await doRevoke();
|
|
1318
|
+
if (!res.ok && (res.status >= 500 || res.status === 429)) {
|
|
1319
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1320
|
+
res = await doRevoke();
|
|
1321
|
+
}
|
|
1322
|
+
if (!res.ok) {
|
|
1323
|
+
this.debugLog("logout: \u670D\u52A1\u7AEF\u64A4\u9500 refresh_token \u5931\u8D25", { status: res.status });
|
|
1324
|
+
}
|
|
1258
1325
|
} catch {
|
|
1259
1326
|
}
|
|
1260
1327
|
}
|
|
@@ -1265,10 +1332,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1265
1332
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
1266
1333
|
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1267
1334
|
if (postLogoutUri) {
|
|
1268
|
-
logoutUrl.searchParams.set(
|
|
1269
|
-
"post_logout_redirect_uri",
|
|
1270
|
-
postLogoutUri
|
|
1271
|
-
);
|
|
1335
|
+
logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutUri);
|
|
1272
1336
|
}
|
|
1273
1337
|
if (idTokenHint) {
|
|
1274
1338
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
@@ -1326,11 +1390,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
|
1326
1390
|
var LOCK_TTL_MS = 5e3;
|
|
1327
1391
|
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1328
1392
|
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1329
|
-
function broadcastSsoEvent(clientId, type) {
|
|
1393
|
+
function broadcastSsoEvent(clientId, type, tokenData) {
|
|
1330
1394
|
if (typeof BroadcastChannel === "undefined") return;
|
|
1331
1395
|
try {
|
|
1332
1396
|
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1333
|
-
channel.postMessage({
|
|
1397
|
+
channel.postMessage({
|
|
1398
|
+
type,
|
|
1399
|
+
sourceTabId: TAB_ID,
|
|
1400
|
+
...type === "token" && tokenData ? { tokenData } : {}
|
|
1401
|
+
});
|
|
1334
1402
|
channel.close();
|
|
1335
1403
|
} catch {
|
|
1336
1404
|
}
|
|
@@ -1466,7 +1534,7 @@ function SsoProvider({
|
|
|
1466
1534
|
const td = await client.refreshToken();
|
|
1467
1535
|
refreshFailureCountRef.current = 0;
|
|
1468
1536
|
onTokenRefreshed?.(td.access_token);
|
|
1469
|
-
broadcastSsoEvent(client.config.clientId, "token");
|
|
1537
|
+
broadcastSsoEvent(client.config.clientId, "token", td);
|
|
1470
1538
|
loadUser();
|
|
1471
1539
|
} catch (err) {
|
|
1472
1540
|
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
@@ -1494,7 +1562,15 @@ function SsoProvider({
|
|
|
1494
1562
|
const tokenData = getTokenData(client.config.clientId);
|
|
1495
1563
|
if (!tokenData) return;
|
|
1496
1564
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
1497
|
-
|
|
1565
|
+
const lifetimeSec = Math.max(
|
|
1566
|
+
1,
|
|
1567
|
+
(tokenData.expires_at - tokenData.issued_at) / 1e3
|
|
1568
|
+
);
|
|
1569
|
+
const effectiveThreshold = Math.max(
|
|
1570
|
+
1,
|
|
1571
|
+
Math.min(refreshThreshold, Math.floor(lifetimeSec / 3))
|
|
1572
|
+
);
|
|
1573
|
+
if (remainingSec <= 0 || remainingSec <= effectiveThreshold) {
|
|
1498
1574
|
refreshTimerRef.current = setTimeout(
|
|
1499
1575
|
() => {
|
|
1500
1576
|
if (active) void attemptRefresh();
|
|
@@ -1503,13 +1579,13 @@ function SsoProvider({
|
|
|
1503
1579
|
);
|
|
1504
1580
|
return;
|
|
1505
1581
|
}
|
|
1506
|
-
const delayMs = Math.max((remainingSec -
|
|
1582
|
+
const delayMs = Math.max((remainingSec - effectiveThreshold) * 1e3, minDelayMs, 1e3);
|
|
1507
1583
|
refreshTimerRef.current = setTimeout(() => {
|
|
1508
1584
|
if (!active) return;
|
|
1509
1585
|
const td = getTokenData(client.config.clientId);
|
|
1510
1586
|
if (!td) return;
|
|
1511
1587
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
1512
|
-
if (secLeft <=
|
|
1588
|
+
if (secLeft <= effectiveThreshold) {
|
|
1513
1589
|
void attemptRefresh();
|
|
1514
1590
|
} else {
|
|
1515
1591
|
scheduleNextRefresh();
|
|
@@ -1524,7 +1600,7 @@ function SsoProvider({
|
|
|
1524
1600
|
refreshTimerRef.current = null;
|
|
1525
1601
|
}
|
|
1526
1602
|
};
|
|
1527
|
-
}, [client, loadUser, refreshThreshold, onTokenRefreshed]);
|
|
1603
|
+
}, [client, loadUser, refreshThreshold, onTokenRefreshed, markSessionExpired]);
|
|
1528
1604
|
(0, import_react.useEffect)(() => {
|
|
1529
1605
|
const handleStorageChange = (e) => {
|
|
1530
1606
|
if (e.key && e.key.startsWith("nihplod_sso_") && !e.key.startsWith(REFRESH_LOCK_PREFIX)) {
|
|
@@ -1549,6 +1625,10 @@ function SsoProvider({
|
|
|
1549
1625
|
sessionExpiredNotifiedRef.current = false;
|
|
1550
1626
|
refreshFailureCountRef.current = 0;
|
|
1551
1627
|
} else if (data.type === "token") {
|
|
1628
|
+
const td = data.tokenData;
|
|
1629
|
+
if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
|
|
1630
|
+
saveTokenData(td, client.config.clientId);
|
|
1631
|
+
}
|
|
1552
1632
|
loadUser();
|
|
1553
1633
|
}
|
|
1554
1634
|
};
|
|
@@ -1615,6 +1695,26 @@ function useSso() {
|
|
|
1615
1695
|
|
|
1616
1696
|
// src/react/RequireAuth.tsx
|
|
1617
1697
|
var import_react2 = __toESM(require("react"));
|
|
1698
|
+
function LoginErrorContent({
|
|
1699
|
+
loginError,
|
|
1700
|
+
retry,
|
|
1701
|
+
renderLoginError
|
|
1702
|
+
}) {
|
|
1703
|
+
if (renderLoginError) {
|
|
1704
|
+
return import_react2.default.createElement(
|
|
1705
|
+
import_react2.default.Fragment,
|
|
1706
|
+
null,
|
|
1707
|
+
renderLoginError(loginError, retry)
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1711
|
+
return import_react2.default.createElement(
|
|
1712
|
+
"div",
|
|
1713
|
+
null,
|
|
1714
|
+
import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1715
|
+
import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1618
1718
|
function RequireAuth({
|
|
1619
1719
|
children,
|
|
1620
1720
|
fallback,
|
|
@@ -1660,20 +1760,11 @@ function RequireAuth({
|
|
|
1660
1760
|
}
|
|
1661
1761
|
if (!isAuthenticated) {
|
|
1662
1762
|
if (loginError) {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
);
|
|
1669
|
-
}
|
|
1670
|
-
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1671
|
-
return import_react2.default.createElement(
|
|
1672
|
-
"div",
|
|
1673
|
-
null,
|
|
1674
|
-
import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1675
|
-
import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1676
|
-
);
|
|
1763
|
+
return import_react2.default.createElement(LoginErrorContent, {
|
|
1764
|
+
loginError,
|
|
1765
|
+
retry,
|
|
1766
|
+
renderLoginError
|
|
1767
|
+
});
|
|
1677
1768
|
}
|
|
1678
1769
|
if (fallback) return import_react2.default.createElement(import_react2.default.Fragment, null, fallback);
|
|
1679
1770
|
return import_react2.default.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
|