@nihplod/sso-sdk 1.5.0 → 1.6.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 +25 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +79 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +79 -6
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +38 -2
- package/dist/next/index.d.ts +38 -2
- package/dist/next/index.js +82 -20
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +82 -20
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.js +90 -9
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +90 -9
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.mjs
CHANGED
|
@@ -505,7 +505,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
505
505
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
506
506
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
507
507
|
}
|
|
508
|
-
if (typeof payload.iat
|
|
508
|
+
if (typeof payload.iat !== "number") {
|
|
509
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
510
|
+
}
|
|
511
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
509
512
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
510
513
|
}
|
|
511
514
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -522,13 +525,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
522
525
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
523
526
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
524
527
|
}
|
|
528
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
529
|
+
throw new SsoError(
|
|
530
|
+
"id_token_nonce_mismatch",
|
|
531
|
+
"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"
|
|
532
|
+
);
|
|
525
533
|
}
|
|
526
534
|
return { sub: payload.sub };
|
|
527
535
|
}
|
|
528
536
|
|
|
529
|
-
// src/core/
|
|
537
|
+
// src/core/http.ts
|
|
530
538
|
var REQUEST_TIMEOUT_MS = 1e4;
|
|
531
|
-
var REVOKE_TIMEOUT_MS = 3e3;
|
|
532
539
|
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
533
540
|
const controller = new AbortController();
|
|
534
541
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -538,6 +545,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
|
|
|
538
545
|
clearTimeout(timer);
|
|
539
546
|
}
|
|
540
547
|
}
|
|
548
|
+
|
|
549
|
+
// src/core/SsoClient.ts
|
|
550
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
551
|
+
var EXPIRY_SKEW_MS = 1e4;
|
|
541
552
|
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
542
553
|
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
543
554
|
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
@@ -565,6 +576,8 @@ var _SsoClient = class _SsoClient {
|
|
|
565
576
|
this._discovery = null;
|
|
566
577
|
this._discoveryFetchedAt = 0;
|
|
567
578
|
this._refreshLock = null;
|
|
579
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
580
|
+
this._discoveryOriginWarned = false;
|
|
568
581
|
if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
|
|
569
582
|
if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
|
|
570
583
|
if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
|
|
@@ -588,6 +601,52 @@ var _SsoClient = class _SsoClient {
|
|
|
588
601
|
// ============================================
|
|
589
602
|
// 内部方法
|
|
590
603
|
// ============================================
|
|
604
|
+
/**
|
|
605
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
606
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
607
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
608
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
609
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
610
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
611
|
+
*/
|
|
612
|
+
_sanitizeDiscovery(d) {
|
|
613
|
+
if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
|
|
614
|
+
const allowedOrigins = /* @__PURE__ */ new Set();
|
|
615
|
+
try {
|
|
616
|
+
allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
|
|
617
|
+
allowedOrigins.add(new URL(this._serverBase).origin);
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const sameOrigin = (endpoint) => {
|
|
622
|
+
try {
|
|
623
|
+
return allowedOrigins.has(new URL(endpoint).origin);
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
|
|
629
|
+
let authorizationOk = false;
|
|
630
|
+
try {
|
|
631
|
+
authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
|
|
632
|
+
} catch {
|
|
633
|
+
authorizationOk = false;
|
|
634
|
+
}
|
|
635
|
+
const bad = [];
|
|
636
|
+
if (!authorizationOk) bad.push("authorization_endpoint");
|
|
637
|
+
if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
|
|
638
|
+
if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
|
|
639
|
+
if (bad.length > 0) {
|
|
640
|
+
if (!this._discoveryOriginWarned) {
|
|
641
|
+
this._discoveryOriginWarned = true;
|
|
642
|
+
console.warn(
|
|
643
|
+
`[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`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return d;
|
|
649
|
+
}
|
|
591
650
|
/**
|
|
592
651
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
593
652
|
*
|
|
@@ -614,7 +673,10 @@ var _SsoClient = class _SsoClient {
|
|
|
614
673
|
);
|
|
615
674
|
return null;
|
|
616
675
|
}
|
|
617
|
-
|
|
676
|
+
const doc = await res.json();
|
|
677
|
+
const sanitized = this._sanitizeDiscovery(doc);
|
|
678
|
+
if (!sanitized) return null;
|
|
679
|
+
this._discovery = sanitized;
|
|
618
680
|
this._discoveryFetchedAt = now;
|
|
619
681
|
return this._discovery;
|
|
620
682
|
} catch (err) {
|
|
@@ -974,6 +1036,10 @@ var _SsoClient = class _SsoClient {
|
|
|
974
1036
|
throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
|
|
975
1037
|
}
|
|
976
1038
|
if (!res.ok) {
|
|
1039
|
+
removeOAuthState(this.config.clientId);
|
|
1040
|
+
removeOAuthNonce(this.config.clientId);
|
|
1041
|
+
removePkceVerifier(this.config.clientId);
|
|
1042
|
+
removeSilentProbe(this.config.clientId);
|
|
977
1043
|
let errData = {};
|
|
978
1044
|
try {
|
|
979
1045
|
errData = await res.json();
|
|
@@ -989,6 +1055,10 @@ var _SsoClient = class _SsoClient {
|
|
|
989
1055
|
assertValidTokenResponse(data, true);
|
|
990
1056
|
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
991
1057
|
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1058
|
+
removeOAuthState(this.config.clientId);
|
|
1059
|
+
removeOAuthNonce(this.config.clientId);
|
|
1060
|
+
removePkceVerifier(this.config.clientId);
|
|
1061
|
+
removeSilentProbe(this.config.clientId);
|
|
992
1062
|
removeTokenData(this.config.clientId);
|
|
993
1063
|
throw new SsoError(
|
|
994
1064
|
"id_token_invalid",
|
|
@@ -1133,7 +1203,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1133
1203
|
if (!tokenData) {
|
|
1134
1204
|
throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
|
|
1135
1205
|
}
|
|
1136
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1206
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1137
1207
|
tokenData = await this.refreshToken();
|
|
1138
1208
|
}
|
|
1139
1209
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
@@ -1168,7 +1238,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1168
1238
|
async getAccessToken() {
|
|
1169
1239
|
let tokenData = getTokenData(this.config.clientId);
|
|
1170
1240
|
if (!tokenData) return null;
|
|
1171
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1241
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1172
1242
|
tokenData = await this.refreshToken();
|
|
1173
1243
|
}
|
|
1174
1244
|
return tokenData.access_token;
|
|
@@ -1177,6 +1247,9 @@ var _SsoClient = class _SsoClient {
|
|
|
1177
1247
|
* 检查是否已认证(不发起网络请求)
|
|
1178
1248
|
*
|
|
1179
1249
|
* 仅检查本地是否存在未过期的 access_token。
|
|
1250
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
1251
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
1252
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
1180
1253
|
*/
|
|
1181
1254
|
isAuthenticated() {
|
|
1182
1255
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -1292,11 +1365,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
|
1292
1365
|
var LOCK_TTL_MS = 5e3;
|
|
1293
1366
|
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1294
1367
|
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1295
|
-
function broadcastSsoEvent(clientId, type) {
|
|
1368
|
+
function broadcastSsoEvent(clientId, type, tokenData) {
|
|
1296
1369
|
if (typeof BroadcastChannel === "undefined") return;
|
|
1297
1370
|
try {
|
|
1298
1371
|
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1299
|
-
channel.postMessage({
|
|
1372
|
+
channel.postMessage({
|
|
1373
|
+
type,
|
|
1374
|
+
sourceTabId: TAB_ID,
|
|
1375
|
+
...type === "token" && tokenData ? { tokenData } : {}
|
|
1376
|
+
});
|
|
1300
1377
|
channel.close();
|
|
1301
1378
|
} catch {
|
|
1302
1379
|
}
|
|
@@ -1432,7 +1509,7 @@ function SsoProvider({
|
|
|
1432
1509
|
const td = await client.refreshToken();
|
|
1433
1510
|
refreshFailureCountRef.current = 0;
|
|
1434
1511
|
onTokenRefreshed?.(td.access_token);
|
|
1435
|
-
broadcastSsoEvent(client.config.clientId, "token");
|
|
1512
|
+
broadcastSsoEvent(client.config.clientId, "token", td);
|
|
1436
1513
|
loadUser();
|
|
1437
1514
|
} catch (err) {
|
|
1438
1515
|
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
@@ -1515,6 +1592,10 @@ function SsoProvider({
|
|
|
1515
1592
|
sessionExpiredNotifiedRef.current = false;
|
|
1516
1593
|
refreshFailureCountRef.current = 0;
|
|
1517
1594
|
} else if (data.type === "token") {
|
|
1595
|
+
const td = data.tokenData;
|
|
1596
|
+
if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
|
|
1597
|
+
saveTokenData(td, client.config.clientId);
|
|
1598
|
+
}
|
|
1518
1599
|
loadUser();
|
|
1519
1600
|
}
|
|
1520
1601
|
};
|