@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.d.mts
CHANGED
|
@@ -58,6 +58,17 @@ interface SsoClientConfig {
|
|
|
58
58
|
redirectUri: string;
|
|
59
59
|
/** SSO 中心地址,如 "https://nihplod.cn" */
|
|
60
60
|
ssoBaseUrl: string;
|
|
61
|
+
/**
|
|
62
|
+
* 允许 Discovery 文档声明跨源端点(默认 false)。
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
|
|
65
|
+
* token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
|
|
66
|
+
* SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
|
|
67
|
+
* token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
|
|
68
|
+
* 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
|
|
69
|
+
* 才设置此开关,并自行确保 Discovery 通道可信。
|
|
70
|
+
*/
|
|
71
|
+
allowCrossOriginDiscoveryEndpoints?: boolean;
|
|
61
72
|
/** 请求的 scope(空格分隔),如 "openid profile phone" */
|
|
62
73
|
scopes?: string;
|
|
63
74
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
@@ -128,6 +139,8 @@ declare class SsoClient {
|
|
|
128
139
|
private _discovery;
|
|
129
140
|
private _discoveryFetchedAt;
|
|
130
141
|
private _refreshLock;
|
|
142
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
143
|
+
private _discoveryOriginWarned;
|
|
131
144
|
/** Discovery 文档缓存 TTL(5 分钟) */
|
|
132
145
|
private static readonly DISCOVERY_TTL_MS;
|
|
133
146
|
/** Discovery fetch 超时(10 秒) */
|
|
@@ -139,6 +152,15 @@ declare class SsoClient {
|
|
|
139
152
|
* 不会输出 token/授权码等敏感值。
|
|
140
153
|
*/
|
|
141
154
|
debugLog(...args: unknown[]): void;
|
|
155
|
+
/**
|
|
156
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
157
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
158
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
159
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
160
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
161
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
162
|
+
*/
|
|
163
|
+
private _sanitizeDiscovery;
|
|
142
164
|
/**
|
|
143
165
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
144
166
|
*
|
|
@@ -260,6 +282,9 @@ declare class SsoClient {
|
|
|
260
282
|
* 检查是否已认证(不发起网络请求)
|
|
261
283
|
*
|
|
262
284
|
* 仅检查本地是否存在未过期的 access_token。
|
|
285
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
286
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
287
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
263
288
|
*/
|
|
264
289
|
isAuthenticated(): boolean;
|
|
265
290
|
/**
|
package/dist/react/index.d.ts
CHANGED
|
@@ -58,6 +58,17 @@ interface SsoClientConfig {
|
|
|
58
58
|
redirectUri: string;
|
|
59
59
|
/** SSO 中心地址,如 "https://nihplod.cn" */
|
|
60
60
|
ssoBaseUrl: string;
|
|
61
|
+
/**
|
|
62
|
+
* 允许 Discovery 文档声明跨源端点(默认 false)。
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
|
|
65
|
+
* token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
|
|
66
|
+
* SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
|
|
67
|
+
* token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
|
|
68
|
+
* 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
|
|
69
|
+
* 才设置此开关,并自行确保 Discovery 通道可信。
|
|
70
|
+
*/
|
|
71
|
+
allowCrossOriginDiscoveryEndpoints?: boolean;
|
|
61
72
|
/** 请求的 scope(空格分隔),如 "openid profile phone" */
|
|
62
73
|
scopes?: string;
|
|
63
74
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
@@ -128,6 +139,8 @@ declare class SsoClient {
|
|
|
128
139
|
private _discovery;
|
|
129
140
|
private _discoveryFetchedAt;
|
|
130
141
|
private _refreshLock;
|
|
142
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
143
|
+
private _discoveryOriginWarned;
|
|
131
144
|
/** Discovery 文档缓存 TTL(5 分钟) */
|
|
132
145
|
private static readonly DISCOVERY_TTL_MS;
|
|
133
146
|
/** Discovery fetch 超时(10 秒) */
|
|
@@ -139,6 +152,15 @@ declare class SsoClient {
|
|
|
139
152
|
* 不会输出 token/授权码等敏感值。
|
|
140
153
|
*/
|
|
141
154
|
debugLog(...args: unknown[]): void;
|
|
155
|
+
/**
|
|
156
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
157
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
158
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
159
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
160
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
161
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
162
|
+
*/
|
|
163
|
+
private _sanitizeDiscovery;
|
|
142
164
|
/**
|
|
143
165
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
144
166
|
*
|
|
@@ -260,6 +282,9 @@ declare class SsoClient {
|
|
|
260
282
|
* 检查是否已认证(不发起网络请求)
|
|
261
283
|
*
|
|
262
284
|
* 仅检查本地是否存在未过期的 access_token。
|
|
285
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
286
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
287
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
263
288
|
*/
|
|
264
289
|
isAuthenticated(): boolean;
|
|
265
290
|
/**
|
package/dist/react/index.js
CHANGED
|
@@ -539,7 +539,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
539
539
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
540
540
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
541
541
|
}
|
|
542
|
-
if (typeof payload.iat
|
|
542
|
+
if (typeof payload.iat !== "number") {
|
|
543
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
544
|
+
}
|
|
545
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
543
546
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
544
547
|
}
|
|
545
548
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -556,13 +559,17 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
556
559
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
557
560
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
558
561
|
}
|
|
562
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
563
|
+
throw new SsoError(
|
|
564
|
+
"id_token_nonce_mismatch",
|
|
565
|
+
"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"
|
|
566
|
+
);
|
|
559
567
|
}
|
|
560
568
|
return { sub: payload.sub };
|
|
561
569
|
}
|
|
562
570
|
|
|
563
|
-
// src/core/
|
|
571
|
+
// src/core/http.ts
|
|
564
572
|
var REQUEST_TIMEOUT_MS = 1e4;
|
|
565
|
-
var REVOKE_TIMEOUT_MS = 3e3;
|
|
566
573
|
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
567
574
|
const controller = new AbortController();
|
|
568
575
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -572,6 +579,10 @@ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS
|
|
|
572
579
|
clearTimeout(timer);
|
|
573
580
|
}
|
|
574
581
|
}
|
|
582
|
+
|
|
583
|
+
// src/core/SsoClient.ts
|
|
584
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
585
|
+
var EXPIRY_SKEW_MS = 1e4;
|
|
575
586
|
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
576
587
|
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
577
588
|
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
@@ -599,6 +610,8 @@ var _SsoClient = class _SsoClient {
|
|
|
599
610
|
this._discovery = null;
|
|
600
611
|
this._discoveryFetchedAt = 0;
|
|
601
612
|
this._refreshLock = null;
|
|
613
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
614
|
+
this._discoveryOriginWarned = false;
|
|
602
615
|
if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
|
|
603
616
|
if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
|
|
604
617
|
if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
|
|
@@ -622,6 +635,52 @@ var _SsoClient = class _SsoClient {
|
|
|
622
635
|
// ============================================
|
|
623
636
|
// 内部方法
|
|
624
637
|
// ============================================
|
|
638
|
+
/**
|
|
639
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
640
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
641
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
642
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
643
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
644
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
645
|
+
*/
|
|
646
|
+
_sanitizeDiscovery(d) {
|
|
647
|
+
if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
|
|
648
|
+
const allowedOrigins = /* @__PURE__ */ new Set();
|
|
649
|
+
try {
|
|
650
|
+
allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
|
|
651
|
+
allowedOrigins.add(new URL(this._serverBase).origin);
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const sameOrigin = (endpoint) => {
|
|
656
|
+
try {
|
|
657
|
+
return allowedOrigins.has(new URL(endpoint).origin);
|
|
658
|
+
} catch {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
|
|
663
|
+
let authorizationOk = false;
|
|
664
|
+
try {
|
|
665
|
+
authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
|
|
666
|
+
} catch {
|
|
667
|
+
authorizationOk = false;
|
|
668
|
+
}
|
|
669
|
+
const bad = [];
|
|
670
|
+
if (!authorizationOk) bad.push("authorization_endpoint");
|
|
671
|
+
if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
|
|
672
|
+
if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
|
|
673
|
+
if (bad.length > 0) {
|
|
674
|
+
if (!this._discoveryOriginWarned) {
|
|
675
|
+
this._discoveryOriginWarned = true;
|
|
676
|
+
console.warn(
|
|
677
|
+
`[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`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
return d;
|
|
683
|
+
}
|
|
625
684
|
/**
|
|
626
685
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
627
686
|
*
|
|
@@ -648,7 +707,10 @@ var _SsoClient = class _SsoClient {
|
|
|
648
707
|
);
|
|
649
708
|
return null;
|
|
650
709
|
}
|
|
651
|
-
|
|
710
|
+
const doc = await res.json();
|
|
711
|
+
const sanitized = this._sanitizeDiscovery(doc);
|
|
712
|
+
if (!sanitized) return null;
|
|
713
|
+
this._discovery = sanitized;
|
|
652
714
|
this._discoveryFetchedAt = now;
|
|
653
715
|
return this._discovery;
|
|
654
716
|
} catch (err) {
|
|
@@ -1008,6 +1070,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1008
1070
|
throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
|
|
1009
1071
|
}
|
|
1010
1072
|
if (!res.ok) {
|
|
1073
|
+
removeOAuthState(this.config.clientId);
|
|
1074
|
+
removeOAuthNonce(this.config.clientId);
|
|
1075
|
+
removePkceVerifier(this.config.clientId);
|
|
1076
|
+
removeSilentProbe(this.config.clientId);
|
|
1011
1077
|
let errData = {};
|
|
1012
1078
|
try {
|
|
1013
1079
|
errData = await res.json();
|
|
@@ -1023,6 +1089,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1023
1089
|
assertValidTokenResponse(data, true);
|
|
1024
1090
|
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
1025
1091
|
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1092
|
+
removeOAuthState(this.config.clientId);
|
|
1093
|
+
removeOAuthNonce(this.config.clientId);
|
|
1094
|
+
removePkceVerifier(this.config.clientId);
|
|
1095
|
+
removeSilentProbe(this.config.clientId);
|
|
1026
1096
|
removeTokenData(this.config.clientId);
|
|
1027
1097
|
throw new SsoError(
|
|
1028
1098
|
"id_token_invalid",
|
|
@@ -1167,7 +1237,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1167
1237
|
if (!tokenData) {
|
|
1168
1238
|
throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
|
|
1169
1239
|
}
|
|
1170
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1240
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1171
1241
|
tokenData = await this.refreshToken();
|
|
1172
1242
|
}
|
|
1173
1243
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
@@ -1202,7 +1272,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1202
1272
|
async getAccessToken() {
|
|
1203
1273
|
let tokenData = getTokenData(this.config.clientId);
|
|
1204
1274
|
if (!tokenData) return null;
|
|
1205
|
-
if (Date.now() >= tokenData.expires_at) {
|
|
1275
|
+
if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
|
|
1206
1276
|
tokenData = await this.refreshToken();
|
|
1207
1277
|
}
|
|
1208
1278
|
return tokenData.access_token;
|
|
@@ -1211,6 +1281,9 @@ var _SsoClient = class _SsoClient {
|
|
|
1211
1281
|
* 检查是否已认证(不发起网络请求)
|
|
1212
1282
|
*
|
|
1213
1283
|
* 仅检查本地是否存在未过期的 access_token。
|
|
1284
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
1285
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
1286
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
1214
1287
|
*/
|
|
1215
1288
|
isAuthenticated() {
|
|
1216
1289
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -1326,11 +1399,15 @@ var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
|
1326
1399
|
var LOCK_TTL_MS = 5e3;
|
|
1327
1400
|
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1328
1401
|
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1329
|
-
function broadcastSsoEvent(clientId, type) {
|
|
1402
|
+
function broadcastSsoEvent(clientId, type, tokenData) {
|
|
1330
1403
|
if (typeof BroadcastChannel === "undefined") return;
|
|
1331
1404
|
try {
|
|
1332
1405
|
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1333
|
-
channel.postMessage({
|
|
1406
|
+
channel.postMessage({
|
|
1407
|
+
type,
|
|
1408
|
+
sourceTabId: TAB_ID,
|
|
1409
|
+
...type === "token" && tokenData ? { tokenData } : {}
|
|
1410
|
+
});
|
|
1334
1411
|
channel.close();
|
|
1335
1412
|
} catch {
|
|
1336
1413
|
}
|
|
@@ -1466,7 +1543,7 @@ function SsoProvider({
|
|
|
1466
1543
|
const td = await client.refreshToken();
|
|
1467
1544
|
refreshFailureCountRef.current = 0;
|
|
1468
1545
|
onTokenRefreshed?.(td.access_token);
|
|
1469
|
-
broadcastSsoEvent(client.config.clientId, "token");
|
|
1546
|
+
broadcastSsoEvent(client.config.clientId, "token", td);
|
|
1470
1547
|
loadUser();
|
|
1471
1548
|
} catch (err) {
|
|
1472
1549
|
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
@@ -1549,6 +1626,10 @@ function SsoProvider({
|
|
|
1549
1626
|
sessionExpiredNotifiedRef.current = false;
|
|
1550
1627
|
refreshFailureCountRef.current = 0;
|
|
1551
1628
|
} else if (data.type === "token") {
|
|
1629
|
+
const td = data.tokenData;
|
|
1630
|
+
if (td && typeof td.access_token === "string" && typeof td.expires_at === "number" && typeof td.refresh_token === "string") {
|
|
1631
|
+
saveTokenData(td, client.config.clientId);
|
|
1632
|
+
}
|
|
1552
1633
|
loadUser();
|
|
1553
1634
|
}
|
|
1554
1635
|
};
|