@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.mjs
CHANGED
|
@@ -90,24 +90,6 @@ var SILENT_PROBE_KEY = "silent_probe";
|
|
|
90
90
|
function buildKey(base, clientId) {
|
|
91
91
|
return clientId ? `${base}:${clientId}` : base;
|
|
92
92
|
}
|
|
93
|
-
function createMemoryStorageAdapter() {
|
|
94
|
-
const store = /* @__PURE__ */ new Map();
|
|
95
|
-
return {
|
|
96
|
-
get(key) {
|
|
97
|
-
return store.get(key) ?? null;
|
|
98
|
-
},
|
|
99
|
-
set(key, value) {
|
|
100
|
-
store.set(key, value);
|
|
101
|
-
},
|
|
102
|
-
remove(key) {
|
|
103
|
-
store.delete(key);
|
|
104
|
-
},
|
|
105
|
-
keys() {
|
|
106
|
-
return [...store.keys()];
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
111
93
|
function createSessionStorageAdapter() {
|
|
112
94
|
const fallback = /* @__PURE__ */ new Map();
|
|
113
95
|
return {
|
|
@@ -375,7 +357,19 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
375
357
|
return cachedJwks.jwks;
|
|
376
358
|
}
|
|
377
359
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
378
|
-
|
|
360
|
+
let jwksUri = `${baseUrl}/api/oauth/jwks`;
|
|
361
|
+
if (discovery?.jwks_uri) {
|
|
362
|
+
try {
|
|
363
|
+
if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
|
|
364
|
+
jwksUri = discovery.jwks_uri;
|
|
365
|
+
} else {
|
|
366
|
+
console.warn(
|
|
367
|
+
"[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
}
|
|
372
|
+
}
|
|
379
373
|
try {
|
|
380
374
|
const res = await fetch(jwksUri, {
|
|
381
375
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -505,7 +499,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
505
499
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
506
500
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
507
501
|
}
|
|
508
|
-
if (typeof payload.iat
|
|
502
|
+
if (typeof payload.iat !== "number") {
|
|
503
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
504
|
+
}
|
|
505
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
509
506
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
510
507
|
}
|
|
511
508
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -522,13 +519,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
522
519
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
523
520
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
524
521
|
}
|
|
522
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
523
|
+
throw new SsoError(
|
|
524
|
+
"id_token_nonce_mismatch",
|
|
525
|
+
"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"
|
|
526
|
+
);
|
|
525
527
|
}
|
|
526
528
|
return { sub: payload.sub };
|
|
527
529
|
}
|
|
528
530
|
|
|
529
|
-
// src/core/
|
|
531
|
+
// src/core/http.ts
|
|
530
532
|
var REQUEST_TIMEOUT_MS = 1e4;
|
|
531
|
-
var REVOKE_TIMEOUT_MS = 3e3;
|
|
532
533
|
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
533
534
|
const controller = new AbortController();
|
|
534
535
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -538,6 +539,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
|
|
|
538
539
|
clearTimeout(timer);
|
|
539
540
|
}
|
|
540
541
|
}
|
|
542
|
+
|
|
543
|
+
// src/core/SsoClient.ts
|
|
544
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
545
|
+
var EXPIRY_SKEW_MS = 1e4;
|
|
541
546
|
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
542
547
|
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
543
548
|
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
@@ -555,16 +560,14 @@ function assertValidTokenResponse(data, requireRefreshToken) {
|
|
|
555
560
|
);
|
|
556
561
|
}
|
|
557
562
|
}
|
|
558
|
-
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
559
|
-
"login_required",
|
|
560
|
-
"consent_required",
|
|
561
|
-
"interaction_required"
|
|
562
|
-
]);
|
|
563
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set(["login_required", "consent_required", "interaction_required"]);
|
|
563
564
|
var _SsoClient = class _SsoClient {
|
|
564
565
|
constructor(config) {
|
|
565
566
|
this._discovery = null;
|
|
566
567
|
this._discoveryFetchedAt = 0;
|
|
567
568
|
this._refreshLock = null;
|
|
569
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
570
|
+
this._discoveryOriginWarned = false;
|
|
568
571
|
if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
|
|
569
572
|
if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
|
|
570
573
|
if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
|
|
@@ -588,6 +591,68 @@ var _SsoClient = class _SsoClient {
|
|
|
588
591
|
// ============================================
|
|
589
592
|
// 内部方法
|
|
590
593
|
// ============================================
|
|
594
|
+
/**
|
|
595
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
596
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
597
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
598
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
599
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
600
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
601
|
+
*/
|
|
602
|
+
_sanitizeDiscovery(d) {
|
|
603
|
+
if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
|
|
604
|
+
const allowedOrigins = /* @__PURE__ */ new Set();
|
|
605
|
+
try {
|
|
606
|
+
allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
|
|
607
|
+
allowedOrigins.add(new URL(this._serverBase).origin);
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
const sameOrigin = (endpoint) => {
|
|
612
|
+
try {
|
|
613
|
+
return allowedOrigins.has(new URL(endpoint).origin);
|
|
614
|
+
} catch {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
|
|
619
|
+
let authorizationOk = false;
|
|
620
|
+
try {
|
|
621
|
+
authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
|
|
622
|
+
} catch {
|
|
623
|
+
authorizationOk = false;
|
|
624
|
+
}
|
|
625
|
+
const bad = [];
|
|
626
|
+
if (!authorizationOk) bad.push("authorization_endpoint");
|
|
627
|
+
if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
|
|
628
|
+
if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
|
|
629
|
+
if (d.jwks_uri && !sameOrigin(d.jwks_uri)) bad.push("jwks_uri");
|
|
630
|
+
if (d.revocation_endpoint && !sameOrigin(d.revocation_endpoint)) {
|
|
631
|
+
bad.push("revocation_endpoint");
|
|
632
|
+
}
|
|
633
|
+
if (d.introspection_endpoint && !sameOrigin(d.introspection_endpoint)) {
|
|
634
|
+
bad.push("introspection_endpoint");
|
|
635
|
+
}
|
|
636
|
+
if (d.end_session_endpoint) {
|
|
637
|
+
try {
|
|
638
|
+
if (new URL(d.end_session_endpoint).origin !== publicOrigin) {
|
|
639
|
+
bad.push("end_session_endpoint");
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
bad.push("end_session_endpoint");
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (bad.length > 0) {
|
|
646
|
+
if (!this._discoveryOriginWarned) {
|
|
647
|
+
this._discoveryOriginWarned = true;
|
|
648
|
+
console.warn(
|
|
649
|
+
`[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`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
return d;
|
|
655
|
+
}
|
|
591
656
|
/**
|
|
592
657
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
593
658
|
*
|
|
@@ -602,19 +667,17 @@ var _SsoClient = class _SsoClient {
|
|
|
602
667
|
const url = `${this._serverBase}/api/oauth/.well-known/openid-configuration`;
|
|
603
668
|
try {
|
|
604
669
|
const controller = new AbortController();
|
|
605
|
-
const timeoutId = setTimeout(
|
|
606
|
-
() => controller.abort(),
|
|
607
|
-
_SsoClient.DISCOVERY_TIMEOUT_MS
|
|
608
|
-
);
|
|
670
|
+
const timeoutId = setTimeout(() => controller.abort(), _SsoClient.DISCOVERY_TIMEOUT_MS);
|
|
609
671
|
const res = await fetch(url, { signal: controller.signal });
|
|
610
672
|
clearTimeout(timeoutId);
|
|
611
673
|
if (!res.ok) {
|
|
612
|
-
console.warn(
|
|
613
|
-
`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`
|
|
614
|
-
);
|
|
674
|
+
console.warn(`[SSO SDK] OIDC Discovery \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}, \u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9`);
|
|
615
675
|
return null;
|
|
616
676
|
}
|
|
617
|
-
|
|
677
|
+
const doc = await res.json();
|
|
678
|
+
const sanitized = this._sanitizeDiscovery(doc);
|
|
679
|
+
if (!sanitized) return null;
|
|
680
|
+
this._discovery = sanitized;
|
|
618
681
|
this._discoveryFetchedAt = now;
|
|
619
682
|
return this._discovery;
|
|
620
683
|
} catch (err) {
|
|
@@ -823,10 +886,7 @@ var _SsoClient = class _SsoClient {
|
|
|
823
886
|
removeOAuthState(this.config.clientId);
|
|
824
887
|
removeOAuthNonce(this.config.clientId);
|
|
825
888
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
826
|
-
throw new SsoError(
|
|
827
|
-
"popup_blocked",
|
|
828
|
-
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
829
|
-
);
|
|
889
|
+
throw new SsoError("popup_blocked", "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5");
|
|
830
890
|
}
|
|
831
891
|
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
832
892
|
try {
|
|
@@ -855,9 +915,7 @@ var _SsoClient = class _SsoClient {
|
|
|
855
915
|
}
|
|
856
916
|
this.handleCallback(event.data.callbackUrl).then((tokenData) => {
|
|
857
917
|
if (tokenData === null) {
|
|
858
|
-
reject(
|
|
859
|
-
new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
|
|
860
|
-
);
|
|
918
|
+
reject(new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09"));
|
|
861
919
|
return;
|
|
862
920
|
}
|
|
863
921
|
resolve(tokenData);
|
|
@@ -868,12 +926,7 @@ var _SsoClient = class _SsoClient {
|
|
|
868
926
|
if (!completed) {
|
|
869
927
|
completed = true;
|
|
870
928
|
cleanup();
|
|
871
|
-
reject(
|
|
872
|
-
new SsoError(
|
|
873
|
-
"popup_closed",
|
|
874
|
-
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
875
|
-
)
|
|
876
|
-
);
|
|
929
|
+
reject(new SsoError("popup_closed", "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"));
|
|
877
930
|
}
|
|
878
931
|
}
|
|
879
932
|
}, 500);
|
|
@@ -940,18 +993,24 @@ var _SsoClient = class _SsoClient {
|
|
|
940
993
|
removeOAuthNonce(this.config.clientId);
|
|
941
994
|
removePkceVerifier(this.config.clientId);
|
|
942
995
|
removeSilentProbe(this.config.clientId);
|
|
943
|
-
throw new SsoError(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
996
|
+
throw new SsoError("state_mismatch", "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB");
|
|
997
|
+
}
|
|
998
|
+
const returnedIss = params.get("iss");
|
|
999
|
+
if (returnedIss) {
|
|
1000
|
+
const discovery = await this._getDiscovery();
|
|
1001
|
+
const expectedIssuer = (discovery?.issuer || this.config.ssoBaseUrl).replace(/\/+$/, "");
|
|
1002
|
+
if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
|
|
1003
|
+
removeOAuthState(this.config.clientId);
|
|
1004
|
+
removeOAuthNonce(this.config.clientId);
|
|
1005
|
+
removePkceVerifier(this.config.clientId);
|
|
1006
|
+
removeSilentProbe(this.config.clientId);
|
|
1007
|
+
throw new SsoError("issuer_mismatch", "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 IdP \u6DF7\u53E0\u653B\u51FB");
|
|
1008
|
+
}
|
|
947
1009
|
}
|
|
948
1010
|
const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
|
|
949
1011
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
950
1012
|
if (!verifier) {
|
|
951
|
-
throw new SsoError(
|
|
952
|
-
"pkce_required",
|
|
953
|
-
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
954
|
-
);
|
|
1013
|
+
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");
|
|
955
1014
|
}
|
|
956
1015
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
957
1016
|
const body = new URLSearchParams();
|
|
@@ -974,6 +1033,10 @@ var _SsoClient = class _SsoClient {
|
|
|
974
1033
|
throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
|
|
975
1034
|
}
|
|
976
1035
|
if (!res.ok) {
|
|
1036
|
+
removeOAuthState(this.config.clientId);
|
|
1037
|
+
removeOAuthNonce(this.config.clientId);
|
|
1038
|
+
removePkceVerifier(this.config.clientId);
|
|
1039
|
+
removeSilentProbe(this.config.clientId);
|
|
977
1040
|
let errData = {};
|
|
978
1041
|
try {
|
|
979
1042
|
errData = await res.json();
|
|
@@ -989,11 +1052,12 @@ var _SsoClient = class _SsoClient {
|
|
|
989
1052
|
assertValidTokenResponse(data, true);
|
|
990
1053
|
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
991
1054
|
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1055
|
+
removeOAuthState(this.config.clientId);
|
|
1056
|
+
removeOAuthNonce(this.config.clientId);
|
|
1057
|
+
removePkceVerifier(this.config.clientId);
|
|
1058
|
+
removeSilentProbe(this.config.clientId);
|
|
992
1059
|
removeTokenData(this.config.clientId);
|
|
993
|
-
throw new SsoError(
|
|
994
|
-
"id_token_invalid",
|
|
995
|
-
"Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
|
|
996
|
-
);
|
|
1060
|
+
throw new SsoError("id_token_invalid", "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09");
|
|
997
1061
|
}
|
|
998
1062
|
if (data.id_token) {
|
|
999
1063
|
try {
|
|
@@ -1058,19 +1122,14 @@ var _SsoClient = class _SsoClient {
|
|
|
1058
1122
|
}
|
|
1059
1123
|
let res = null;
|
|
1060
1124
|
let lastErr;
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
break;
|
|
1070
|
-
} catch (err) {
|
|
1071
|
-
lastErr = err;
|
|
1072
|
-
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
1073
|
-
}
|
|
1125
|
+
try {
|
|
1126
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
1127
|
+
method: "POST",
|
|
1128
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1129
|
+
body: body.toString()
|
|
1130
|
+
});
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
lastErr = err;
|
|
1074
1133
|
}
|
|
1075
1134
|
if (lastErr || !res) {
|
|
1076
1135
|
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
@@ -1133,7 +1192,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1133
1192
|
if (!tokenData) {
|
|
1134
1193
|
throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
|
|
1135
1194
|
}
|
|
1136
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1195
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1137
1196
|
tokenData = await this.refreshToken();
|
|
1138
1197
|
}
|
|
1139
1198
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
@@ -1152,10 +1211,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1152
1211
|
removeTokenData(this.config.clientId);
|
|
1153
1212
|
throw new SsoError("not_authenticated", "Token \u5DF2\u5931\u6548");
|
|
1154
1213
|
}
|
|
1155
|
-
throw new SsoError(
|
|
1156
|
-
"userinfo_failed",
|
|
1157
|
-
`\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`
|
|
1158
|
-
);
|
|
1214
|
+
throw new SsoError("userinfo_failed", `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25: HTTP ${res.status}`);
|
|
1159
1215
|
}
|
|
1160
1216
|
return await res.json();
|
|
1161
1217
|
}
|
|
@@ -1168,7 +1224,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1168
1224
|
async getAccessToken() {
|
|
1169
1225
|
let tokenData = getTokenData(this.config.clientId);
|
|
1170
1226
|
if (!tokenData) return null;
|
|
1171
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1227
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1172
1228
|
tokenData = await this.refreshToken();
|
|
1173
1229
|
}
|
|
1174
1230
|
return tokenData.access_token;
|
|
@@ -1177,6 +1233,9 @@ var _SsoClient = class _SsoClient {
|
|
|
1177
1233
|
* 检查是否已认证(不发起网络请求)
|
|
1178
1234
|
*
|
|
1179
1235
|
* 仅检查本地是否存在未过期的 access_token。
|
|
1236
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
1237
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
1238
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
1180
1239
|
*/
|
|
1181
1240
|
isAuthenticated() {
|
|
1182
1241
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -1212,7 +1271,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1212
1271
|
if (this.config.clientSecret) {
|
|
1213
1272
|
revokeBody.set("client_secret", this.config.clientSecret);
|
|
1214
1273
|
}
|
|
1215
|
-
|
|
1274
|
+
const doRevoke = () => fetchWithTimeout(
|
|
1216
1275
|
revokeUrl,
|
|
1217
1276
|
{
|
|
1218
1277
|
method: "POST",
|
|
@@ -1221,6 +1280,14 @@ var _SsoClient = class _SsoClient {
|
|
|
1221
1280
|
},
|
|
1222
1281
|
REVOKE_TIMEOUT_MS
|
|
1223
1282
|
);
|
|
1283
|
+
let res = await doRevoke();
|
|
1284
|
+
if (!res.ok && (res.status >= 500 || res.status === 429)) {
|
|
1285
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1286
|
+
res = await doRevoke();
|
|
1287
|
+
}
|
|
1288
|
+
if (!res.ok) {
|
|
1289
|
+
this.debugLog("logout: \u670D\u52A1\u7AEF\u64A4\u9500 refresh_token \u5931\u8D25", { status: res.status });
|
|
1290
|
+
}
|
|
1224
1291
|
} catch {
|
|
1225
1292
|
}
|
|
1226
1293
|
}
|
|
@@ -1231,10 +1298,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1231
1298
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
1232
1299
|
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1233
1300
|
if (postLogoutUri) {
|
|
1234
|
-
logoutUrl.searchParams.set(
|
|
1235
|
-
"post_logout_redirect_uri",
|
|
1236
|
-
postLogoutUri
|
|
1237
|
-
);
|
|
1301
|
+
logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutUri);
|
|
1238
1302
|
}
|
|
1239
1303
|
if (idTokenHint) {
|
|
1240
1304
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
@@ -1292,11 +1356,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
|
1292
1356
|
var LOCK_TTL_MS = 5e3;
|
|
1293
1357
|
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1294
1358
|
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1295
|
-
function broadcastSsoEvent(clientId, type) {
|
|
1359
|
+
function broadcastSsoEvent(clientId, type, tokenData) {
|
|
1296
1360
|
if (typeof BroadcastChannel === "undefined") return;
|
|
1297
1361
|
try {
|
|
1298
1362
|
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1299
|
-
channel.postMessage({
|
|
1363
|
+
channel.postMessage({
|
|
1364
|
+
type,
|
|
1365
|
+
sourceTabId: TAB_ID,
|
|
1366
|
+
...type === "token" && tokenData ? { tokenData } : {}
|
|
1367
|
+
});
|
|
1300
1368
|
channel.close();
|
|
1301
1369
|
} catch {
|
|
1302
1370
|
}
|
|
@@ -1432,7 +1500,7 @@ function SsoProvider({
|
|
|
1432
1500
|
const td = await client.refreshToken();
|
|
1433
1501
|
refreshFailureCountRef.current = 0;
|
|
1434
1502
|
onTokenRefreshed?.(td.access_token);
|
|
1435
|
-
broadcastSsoEvent(client.config.clientId, "token");
|
|
1503
|
+
broadcastSsoEvent(client.config.clientId, "token", td);
|
|
1436
1504
|
loadUser();
|
|
1437
1505
|
} catch (err) {
|
|
1438
1506
|
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
@@ -1460,7 +1528,15 @@ function SsoProvider({
|
|
|
1460
1528
|
const tokenData = getTokenData(client.config.clientId);
|
|
1461
1529
|
if (!tokenData) return;
|
|
1462
1530
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
1463
|
-
|
|
1531
|
+
const lifetimeSec = Math.max(
|
|
1532
|
+
1,
|
|
1533
|
+
(tokenData.expires_at - tokenData.issued_at) / 1e3
|
|
1534
|
+
);
|
|
1535
|
+
const effectiveThreshold = Math.max(
|
|
1536
|
+
1,
|
|
1537
|
+
Math.min(refreshThreshold, Math.floor(lifetimeSec / 3))
|
|
1538
|
+
);
|
|
1539
|
+
if (remainingSec <= 0 || remainingSec <= effectiveThreshold) {
|
|
1464
1540
|
refreshTimerRef.current = setTimeout(
|
|
1465
1541
|
() => {
|
|
1466
1542
|
if (active) void attemptRefresh();
|
|
@@ -1469,13 +1545,13 @@ function SsoProvider({
|
|
|
1469
1545
|
);
|
|
1470
1546
|
return;
|
|
1471
1547
|
}
|
|
1472
|
-
const delayMs = Math.max((remainingSec -
|
|
1548
|
+
const delayMs = Math.max((remainingSec - effectiveThreshold) * 1e3, minDelayMs, 1e3);
|
|
1473
1549
|
refreshTimerRef.current = setTimeout(() => {
|
|
1474
1550
|
if (!active) return;
|
|
1475
1551
|
const td = getTokenData(client.config.clientId);
|
|
1476
1552
|
if (!td) return;
|
|
1477
1553
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
1478
|
-
if (secLeft <=
|
|
1554
|
+
if (secLeft <= effectiveThreshold) {
|
|
1479
1555
|
void attemptRefresh();
|
|
1480
1556
|
} else {
|
|
1481
1557
|
scheduleNextRefresh();
|
|
@@ -1490,7 +1566,7 @@ function SsoProvider({
|
|
|
1490
1566
|
refreshTimerRef.current = null;
|
|
1491
1567
|
}
|
|
1492
1568
|
};
|
|
1493
|
-
}, [client, loadUser, refreshThreshold, onTokenRefreshed]);
|
|
1569
|
+
}, [client, loadUser, refreshThreshold, onTokenRefreshed, markSessionExpired]);
|
|
1494
1570
|
useEffect(() => {
|
|
1495
1571
|
const handleStorageChange = (e) => {
|
|
1496
1572
|
if (e.key && e.key.startsWith("nihplod_sso_") && !e.key.startsWith(REFRESH_LOCK_PREFIX)) {
|
|
@@ -1515,6 +1591,10 @@ function SsoProvider({
|
|
|
1515
1591
|
sessionExpiredNotifiedRef.current = false;
|
|
1516
1592
|
refreshFailureCountRef.current = 0;
|
|
1517
1593
|
} else if (data.type === "token") {
|
|
1594
|
+
const td = data.tokenData;
|
|
1595
|
+
if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
|
|
1596
|
+
saveTokenData(td, client.config.clientId);
|
|
1597
|
+
}
|
|
1518
1598
|
loadUser();
|
|
1519
1599
|
}
|
|
1520
1600
|
};
|
|
@@ -1581,6 +1661,26 @@ function useSso() {
|
|
|
1581
1661
|
|
|
1582
1662
|
// src/react/RequireAuth.tsx
|
|
1583
1663
|
import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
1664
|
+
function LoginErrorContent({
|
|
1665
|
+
loginError,
|
|
1666
|
+
retry,
|
|
1667
|
+
renderLoginError
|
|
1668
|
+
}) {
|
|
1669
|
+
if (renderLoginError) {
|
|
1670
|
+
return React2.createElement(
|
|
1671
|
+
React2.Fragment,
|
|
1672
|
+
null,
|
|
1673
|
+
renderLoginError(loginError, retry)
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1677
|
+
return React2.createElement(
|
|
1678
|
+
"div",
|
|
1679
|
+
null,
|
|
1680
|
+
React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1681
|
+
React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1584
1684
|
function RequireAuth({
|
|
1585
1685
|
children,
|
|
1586
1686
|
fallback,
|
|
@@ -1626,20 +1726,11 @@ function RequireAuth({
|
|
|
1626
1726
|
}
|
|
1627
1727
|
if (!isAuthenticated) {
|
|
1628
1728
|
if (loginError) {
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
);
|
|
1635
|
-
}
|
|
1636
|
-
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1637
|
-
return React2.createElement(
|
|
1638
|
-
"div",
|
|
1639
|
-
null,
|
|
1640
|
-
React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1641
|
-
React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1642
|
-
);
|
|
1729
|
+
return React2.createElement(LoginErrorContent, {
|
|
1730
|
+
loginError,
|
|
1731
|
+
retry,
|
|
1732
|
+
renderLoginError
|
|
1733
|
+
});
|
|
1643
1734
|
}
|
|
1644
1735
|
if (fallback) return React2.createElement(React2.Fragment, null, fallback);
|
|
1645
1736
|
return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
|