@nihplod/sso-verify 1.2.0 → 2.0.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 +157 -154
- package/dist/index.d.mts +66 -9
- package/dist/index.d.ts +66 -9
- package/dist/index.js +106 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +106 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -37,6 +37,8 @@ var processedLogoutJtis = new import_lru_cache.LRUCache({
|
|
|
37
37
|
max: 1e4,
|
|
38
38
|
ttl: 10 * 60 * 1e3
|
|
39
39
|
});
|
|
40
|
+
var LEGACY_LOGOUT_SECRET_FALLBACK_WARNING = "[sso-verify] \u6B63\u5728\u4F7F\u7528 accessTokenSecret \u9A8C\u8BC1 HS256 logout_token\uFF08\u5E9F\u5F03\u8DEF\u5F84\uFF09\uFF1A\u5171\u4EAB access secret \u7684\u670D\u52A1\u53EF\u4F2A\u9020\u4EFB\u610F\u7528\u6237\u767B\u51FA\u3002\u8BF7\u6539\u7528 logoutTokenSecret\uFF0C\u6216\u914D\u7F6E logoutTokenPublicKey/jwksUri \u8D70 RS256\u3002";
|
|
41
|
+
var warnedInsecureEndpoints = /* @__PURE__ */ new Set();
|
|
40
42
|
function createTokenVerifier(options) {
|
|
41
43
|
const {
|
|
42
44
|
audience,
|
|
@@ -49,16 +51,23 @@ function createTokenVerifier(options) {
|
|
|
49
51
|
jwksUri,
|
|
50
52
|
logoutTokenSecret,
|
|
51
53
|
logoutTokenPublicKey,
|
|
54
|
+
allowLegacyAccessTokenSecretFallback = false,
|
|
52
55
|
introspectCacheTtl = 30 * 1e3,
|
|
53
56
|
introspectTimeoutMs = 10 * 1e3,
|
|
54
57
|
introspectNegativeCacheTtl = 5 * 1e3,
|
|
55
58
|
introspectRetries = 1,
|
|
56
59
|
clockToleranceSeconds = 60,
|
|
57
60
|
logoutJtiStore,
|
|
58
|
-
strictAudience = true
|
|
61
|
+
strictAudience = true,
|
|
62
|
+
verificationMode = "auto",
|
|
63
|
+
allowM2mTokens = false
|
|
59
64
|
} = options;
|
|
65
|
+
let warnedLegacyLogoutSecret = false;
|
|
66
|
+
if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {
|
|
67
|
+
console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);
|
|
68
|
+
}
|
|
60
69
|
const assertHttpsEndpoint = (value, name) => {
|
|
61
|
-
if (!value
|
|
70
|
+
if (!value) return;
|
|
62
71
|
let url;
|
|
63
72
|
try {
|
|
64
73
|
url = new URL(value);
|
|
@@ -69,6 +78,15 @@ function createTokenVerifier(options) {
|
|
|
69
78
|
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
70
79
|
const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
71
80
|
if (url.protocol === "http:" && isLoopback) return;
|
|
81
|
+
if (process.env.NODE_ENV !== "production") {
|
|
82
|
+
if (url.protocol === "http:" && !warnedInsecureEndpoints.has(name)) {
|
|
83
|
+
warnedInsecureEndpoints.add(name);
|
|
84
|
+
console.warn(
|
|
85
|
+
`[sso-verify] ${name} \u4F7F\u7528\u975E\u52A0\u5BC6\u7684 http:// \u4E14\u975E loopback \u5730\u5740\uFF1B\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u5C06\u76F4\u63A5\u629B\u9519`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
72
90
|
throw new Error(
|
|
73
91
|
`[sso-verify] \u751F\u4EA7\u73AF\u5883 ${name} \u5FC5\u987B\u4E3A https://\uFF08\u5F53\u524D ${url.protocol}//${url.host}\uFF09\uFF0C\u907F\u514D\u51ED\u8BC1/\u4EE4\u724C\u7ECF\u660E\u6587\u4F20\u8F93`
|
|
74
92
|
);
|
|
@@ -170,7 +188,9 @@ function createTokenVerifier(options) {
|
|
|
170
188
|
return null;
|
|
171
189
|
}
|
|
172
190
|
if (!response.ok) {
|
|
173
|
-
if (response.status >= 500 && attempt < introspectRetries)
|
|
191
|
+
if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
174
194
|
return null;
|
|
175
195
|
}
|
|
176
196
|
try {
|
|
@@ -206,7 +226,11 @@ function createTokenVerifier(options) {
|
|
|
206
226
|
iss: issuer,
|
|
207
227
|
client_id: data.client_id,
|
|
208
228
|
scope: data.scope,
|
|
209
|
-
exp: data.exp
|
|
229
|
+
exp: data.exp,
|
|
230
|
+
...data.client_type ? { client_type: data.client_type } : {},
|
|
231
|
+
...data.sid ? { sid: data.sid } : {},
|
|
232
|
+
...typeof data.auth_time === "number" ? { auth_time: data.auth_time } : {},
|
|
233
|
+
...data.cnf ? { cnf: data.cnf } : {}
|
|
210
234
|
};
|
|
211
235
|
}
|
|
212
236
|
let ttl = introspectCacheTtl;
|
|
@@ -233,6 +257,7 @@ function createTokenVerifier(options) {
|
|
|
233
257
|
issuer,
|
|
234
258
|
audience,
|
|
235
259
|
algorithms: ["HS256"],
|
|
260
|
+
requiredClaims: ["exp"],
|
|
236
261
|
clockTolerance: clockToleranceSeconds
|
|
237
262
|
});
|
|
238
263
|
if (payload.type !== "access_token") {
|
|
@@ -250,14 +275,18 @@ function createTokenVerifier(options) {
|
|
|
250
275
|
try {
|
|
251
276
|
const directKey = await getRS256PublicKey();
|
|
252
277
|
if (directKey) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
278
|
+
try {
|
|
279
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
|
|
280
|
+
issuer,
|
|
281
|
+
audience,
|
|
282
|
+
algorithms: ["RS256"],
|
|
283
|
+
requiredClaims: ["exp"],
|
|
284
|
+
clockTolerance: clockToleranceSeconds
|
|
285
|
+
});
|
|
286
|
+
if (payload.type !== "access_token") return null;
|
|
287
|
+
return normalizeLocalPayload(payload);
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
261
290
|
}
|
|
262
291
|
const jwks = getJwksKeySet();
|
|
263
292
|
if (jwks) {
|
|
@@ -265,6 +294,7 @@ function createTokenVerifier(options) {
|
|
|
265
294
|
issuer,
|
|
266
295
|
audience,
|
|
267
296
|
algorithms: ["RS256"],
|
|
297
|
+
requiredClaims: ["exp"],
|
|
268
298
|
clockTolerance: clockToleranceSeconds
|
|
269
299
|
});
|
|
270
300
|
if (payload.type !== "access_token") return null;
|
|
@@ -275,34 +305,63 @@ function createTokenVerifier(options) {
|
|
|
275
305
|
return null;
|
|
276
306
|
}
|
|
277
307
|
}
|
|
308
|
+
const hasIntrospection = Boolean(introspectionEndpoint && clientId);
|
|
309
|
+
const rejectM2mPayload = (payload) => {
|
|
310
|
+
if (!payload || allowM2mTokens) return payload;
|
|
311
|
+
const isM2m = payload.client_type === "m2m" || payload.sub.startsWith("client:");
|
|
312
|
+
return isM2m ? null : payload;
|
|
313
|
+
};
|
|
314
|
+
const verifyLocallyBoth = async (rawToken) => {
|
|
315
|
+
if (accessTokenSecret) {
|
|
316
|
+
const local = await verifyLocally(rawToken);
|
|
317
|
+
if (local) return local;
|
|
318
|
+
}
|
|
319
|
+
return verifyWithRS256(rawToken);
|
|
320
|
+
};
|
|
321
|
+
const verifyViaIntrospection = async (rawToken) => {
|
|
322
|
+
const result = await introspect(rawToken);
|
|
323
|
+
if (result?.active !== true || !result.sub) return null;
|
|
324
|
+
return {
|
|
325
|
+
sub: result.sub,
|
|
326
|
+
aud: audience,
|
|
327
|
+
iss: issuer,
|
|
328
|
+
client_id: result.client_id,
|
|
329
|
+
scope: result.scope,
|
|
330
|
+
exp: result.exp,
|
|
331
|
+
...result.client_type ? { client_type: result.client_type } : {},
|
|
332
|
+
...result.sid ? { sid: result.sid } : {},
|
|
333
|
+
...typeof result.auth_time === "number" ? { auth_time: result.auth_time } : {},
|
|
334
|
+
...result.cnf ? { cnf: result.cnf } : {},
|
|
335
|
+
...result.token_type ? { token_type: result.token_type } : {}
|
|
336
|
+
};
|
|
337
|
+
};
|
|
278
338
|
return {
|
|
279
339
|
/**
|
|
280
340
|
* 验证 Access Token
|
|
281
341
|
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
342
|
+
* 模式由 verificationMode 控制(默认 "auto"):
|
|
343
|
+
* - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
|
|
344
|
+
* - introspection:强制 Introspection(未配置端点时回退本地)。
|
|
345
|
+
* - local:仅本地验签(不感知撤销)。
|
|
346
|
+
* - hybrid:本地优先,失败后 Introspection(旧版行为)。
|
|
286
347
|
*
|
|
287
348
|
* @returns token payload 或者 null(验证失败)
|
|
288
349
|
*/
|
|
289
|
-
async verify(
|
|
290
|
-
|
|
291
|
-
|
|
350
|
+
async verify(rawToken) {
|
|
351
|
+
const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));
|
|
352
|
+
const runIntrospection = async () => rejectM2mPayload(await verifyViaIntrospection(rawToken));
|
|
353
|
+
if (verificationMode === "local") {
|
|
354
|
+
return await runLocal();
|
|
355
|
+
}
|
|
356
|
+
if (verificationMode === "introspection") {
|
|
357
|
+
return hasIntrospection ? await runIntrospection() : await runLocal();
|
|
358
|
+
}
|
|
359
|
+
if (verificationMode === "hybrid") {
|
|
360
|
+
const local = await runLocal();
|
|
292
361
|
if (local) return local;
|
|
362
|
+
return hasIntrospection ? await runIntrospection() : null;
|
|
293
363
|
}
|
|
294
|
-
|
|
295
|
-
if (rs256Result) return rs256Result;
|
|
296
|
-
const result = await introspect(token);
|
|
297
|
-
if (result?.active !== true || !result.sub) return null;
|
|
298
|
-
return {
|
|
299
|
-
sub: result.sub,
|
|
300
|
-
aud: audience,
|
|
301
|
-
iss: issuer,
|
|
302
|
-
client_id: result.client_id,
|
|
303
|
-
scope: result.scope,
|
|
304
|
-
exp: result.exp
|
|
305
|
-
};
|
|
364
|
+
return hasIntrospection ? await runIntrospection() : await runLocal();
|
|
306
365
|
},
|
|
307
366
|
/**
|
|
308
367
|
* 直接调用 Introspection 端点
|
|
@@ -336,6 +395,12 @@ function createTokenVerifier(options) {
|
|
|
336
395
|
if (typeof payload.exp !== "number") {
|
|
337
396
|
return null;
|
|
338
397
|
}
|
|
398
|
+
if (typeof payload.iat !== "number") {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
if (payload.nonce !== void 0) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
339
404
|
const events = payload.events;
|
|
340
405
|
const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
|
|
341
406
|
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
@@ -386,11 +451,11 @@ function createTokenVerifier(options) {
|
|
|
386
451
|
issuer,
|
|
387
452
|
audience,
|
|
388
453
|
algorithms: ["RS256"],
|
|
454
|
+
requiredClaims: ["exp"],
|
|
389
455
|
clockTolerance: clockToleranceSeconds
|
|
390
456
|
});
|
|
391
457
|
return await validateLogoutPayload(payload);
|
|
392
458
|
} catch {
|
|
393
|
-
return null;
|
|
394
459
|
}
|
|
395
460
|
}
|
|
396
461
|
let jwks = null;
|
|
@@ -405,6 +470,7 @@ function createTokenVerifier(options) {
|
|
|
405
470
|
issuer,
|
|
406
471
|
audience,
|
|
407
472
|
algorithms: ["RS256"],
|
|
473
|
+
requiredClaims: ["exp"],
|
|
408
474
|
clockTolerance: clockToleranceSeconds
|
|
409
475
|
});
|
|
410
476
|
return await validateLogoutPayload(payload);
|
|
@@ -415,14 +481,21 @@ function createTokenVerifier(options) {
|
|
|
415
481
|
return null;
|
|
416
482
|
}
|
|
417
483
|
if (alg === "HS256") {
|
|
418
|
-
const secret = logoutTokenSecret || accessTokenSecret;
|
|
484
|
+
const secret = logoutTokenSecret || (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : void 0);
|
|
419
485
|
if (!secret) return null;
|
|
486
|
+
if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {
|
|
487
|
+
if (!warnedLegacyLogoutSecret) {
|
|
488
|
+
warnedLegacyLogoutSecret = true;
|
|
489
|
+
console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
420
492
|
try {
|
|
421
493
|
const key = new TextEncoder().encode(secret);
|
|
422
494
|
const { payload } = await (0, import_jose.jwtVerify)(token, key, {
|
|
423
495
|
issuer,
|
|
424
496
|
audience,
|
|
425
497
|
algorithms: ["HS256"],
|
|
498
|
+
requiredClaims: ["exp"],
|
|
426
499
|
clockTolerance: clockToleranceSeconds
|
|
427
500
|
});
|
|
428
501
|
return await validateLogoutPayload(payload);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @nihplod/sso-verify\n *\n * NIHPLOD 一网通 SSO Token 验证工具包\n *\n * 供子项目在后端验证主站签发的 Access Token。\n *\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\n *\n * 安装:npm install @nihplod/sso-verify\n *\n * 使用:\n * ```typescript\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\n * const verifier = createTokenVerifier({\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\n * clientId: \"advisor\",\n * clientSecret: \"YOUR_CLIENT_SECRET\",\n * audience: \"advisor\",\n * });\n *\n * const payload = await verifier.verify(token);\n * if (payload) {\n * console.log(payload.sub); // 用户 ID\n * }\n * ```\n */\n\nimport {\n jwtVerify,\n importSPKI,\n createRemoteJWKSet,\n decodeProtectedHeader,\n type KeyLike,\n type JWTPayload,\n} from \"jose\";\nimport { LRUCache } from \"lru-cache\";\n\n// ============================================\n// 类型定义\n// ============================================\n\nexport interface SsoVerifierOptions {\n /** 期望的 audience(对应子项目的 client_id) */\n audience: string;\n\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\n issuer?: string;\n\n /**\n * Token Introspection 端点 URL。\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\n */\n introspectionEndpoint?: string;\n\n /** 子项目的 client_id(用于 introspection) */\n clientId?: string;\n\n /** 子项目的 client_secret(用于 introspection) */\n clientSecret?: string;\n\n /**\n * 共享的 Access Token Secret(可选)。\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\n */\n accessTokenSecret?: string;\n\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\n introspectCacheTtl?: number;\n\n /**\n * Introspection 请求超时时间(毫秒),默认 10 秒。\n * 超时按验证失败处理(返回 null)。\n */\n introspectTimeoutMs?: number;\n\n /**\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\n * 设为 0 表示不缓存 active:false 结果。\n */\n introspectNegativeCacheTtl?: number;\n\n /**\n * Access Token RS256 公钥(PEM 格式,可选)。\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\n */\n accessTokenPublicKey?: string;\n\n /**\n * JWKS 端点 URL(可选)。\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥。\n */\n jwksUri?: string;\n\n /**\n * Logout Token Secret(可选)。\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\n * 若未提供,将回退使用 accessTokenSecret 进行验证。\n */\n logoutTokenSecret?: string;\n\n /**\n * Logout Token RS256 公钥(PEM 格式,可选)。\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\n * 不会静默回退到 HS256。\n */\n logoutTokenPublicKey?: string;\n\n /**\n * Introspection 请求失败重试次数,默认 1。\n * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。\n * 设为 0 表示不重试。\n */\n introspectRetries?: number;\n\n /**\n * JWT 时钟偏移容忍(秒),默认 60。\n * 应用于所有本地验签路径(HS256/RS256/logout token),\n * 用于容忍子项目与主站之间的时钟偏差。\n */\n clockToleranceSeconds?: number;\n\n /**\n * Logout Token jti 外部存储(可选)。\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\n */\n logoutJtiStore?: LogoutJtiStore;\n\n /**\n * Introspection audience 严格模式(默认 true,fail-closed)。\n *\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\n */\n strictAudience?: boolean;\n}\n\n/**\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\n *\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\n */\nexport interface LogoutJtiStore {\n /** 判断 jti 是否已处理过 */\n has(key: string): boolean | Promise<boolean>;\n /** 记录已处理的 jti,ttlSeconds 后过期 */\n add(key: string, ttlSeconds: number): void | Promise<void>;\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\n}\n\nexport interface VerifiedTokenPayload extends JWTPayload {\n sub: string;\n aud: string;\n iss: string;\n client_id?: string;\n scope?: string;\n phone?: string;\n}\n\n/**\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\n *\n * 主站签发 logout_token 时包含以下字段:\n * - iss: 主站 issuer\n * - aud: 目标 client_id\n * - sub: 用户 ID(与 sid 至少其一)\n * - iat: 签发时间\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\n */\nexport interface LogoutTokenPayload {\n /** JWT type,logout_token 固定值为 \"logout_token\" */\n type: \"logout_token\";\n /** 签发者 */\n iss: string;\n /** 目标 audience */\n aud: string;\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\n sub?: string;\n /** 签发时间(UNIX timestamp) */\n iat: number;\n /** JWT ID(唯一标识,防重放) */\n jti: string;\n /** Backchannel logout 事件声明 */\n events: {\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\n };\n /** sid (Session ID),可选 */\n sid?: string;\n}\n\ninterface IntrospectResponse {\n active: boolean;\n sub?: string;\n aud?: string | string[];\n client_id?: string;\n scope?: string;\n exp?: number;\n [key: string]: unknown;\n}\n\n// ============================================\n// Introspection 结果缓存\n// ============================================\n\ninterface IntrospectCacheEntry {\n active: boolean;\n payload: VerifiedTokenPayload | null;\n}\n\nfunction createIntrospectCache(ttlMs: number) {\n return new LRUCache<string, IntrospectCacheEntry>({\n max: 10000,\n ttl: ttlMs,\n });\n}\n\n// ============================================\n// Logout Token jti 重放防护缓存\n// ============================================\n\n/**\n * 已处理的 logout_token jti 缓存。\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\n */\nconst processedLogoutJtis = new LRUCache<string, number>({\n max: 10000,\n ttl: 10 * 60 * 1000,\n});\n\n// ============================================\n// Token Verifier\n// ============================================\n\nexport function createTokenVerifier(options: SsoVerifierOptions) {\n const {\n audience,\n issuer = \"https://nihplod.cn\",\n introspectionEndpoint,\n clientId,\n clientSecret,\n accessTokenSecret,\n accessTokenPublicKey,\n jwksUri,\n logoutTokenSecret,\n logoutTokenPublicKey,\n introspectCacheTtl = 30 * 1000,\n introspectTimeoutMs = 10 * 1000,\n introspectNegativeCacheTtl = 5 * 1000,\n introspectRetries = 1,\n clockToleranceSeconds = 60,\n logoutJtiStore,\n strictAudience = true,\n } = options;\n\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\n if (!value || process.env.NODE_ENV !== \"production\") return;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\n }\n if (url.protocol === \"https:\") return;\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\n if (url.protocol === \"http:\" && isLoopback) return;\n throw new Error(\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\n \"避免凭证/令牌经明文传输\"\n );\n };\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\n\n const introspectCache = createIntrospectCache(introspectCacheTtl);\n\n // RS256 JWKS 远程密钥集(延迟初始化)\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\n function getJwksKeySet() {\n if (!jwksUri) return null;\n if (!_jwksKeySet) {\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\n });\n }\n return _jwksKeySet;\n }\n\n // RS256 公钥(延迟导入)\n let _rs256PublicKey: KeyLike | null = null;\n let _rs256KeyInitialized = false;\n async function getRS256PublicKey(): Promise<KeyLike | null> {\n if (!_rs256KeyInitialized) {\n _rs256KeyInitialized = true;\n if (!accessTokenPublicKey) {\n _rs256PublicKey = null;\n } else {\n try {\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\n } catch {\n _rs256PublicKey = null;\n }\n }\n }\n return _rs256PublicKey;\n }\n\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\n let _logoutRs256PublicKey: KeyLike | null = null;\n let _logoutRs256KeyInitialized = false;\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\n if (!_logoutRs256KeyInitialized) {\n _logoutRs256KeyInitialized = true;\n if (!logoutTokenPublicKey) {\n _logoutRs256PublicKey = null;\n } else {\n try {\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\n } catch {\n _logoutRs256PublicKey = null;\n }\n }\n }\n return _logoutRs256PublicKey;\n }\n\n /**\n * Introspection 响应的 aud 归属校验(防 confused deputy):\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\n * 默认 strictAudience=true 时 fail-closed 拒绝。\n */\n function matchesAudience(data: IntrospectResponse): boolean {\n const aud = data.aud;\n if (typeof aud === \"string\") return aud === audience;\n if (Array.isArray(aud)) return aud.includes(audience);\n if (typeof data.client_id === \"string\") return data.client_id === audience;\n return !strictAudience;\n }\n\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\n\n /**\n * 调用主站 Introspection 端点验证 token\n *\n * Public Client 可省略 client_secret,仅传 client_id。\n * Confidential Client 建议传入 client_secret 以通过端点认证。\n */\n async function introspect(token: string): Promise<IntrospectResponse | null> {\n if (!introspectionEndpoint || !clientId) {\n return null;\n }\n\n const cached = introspectCache.get(token);\n if (cached) {\n return { active: cached.active, ...(cached.payload || {}) };\n }\n\n const inflight = inflightIntrospects.get(token);\n if (inflight) {\n return inflight;\n }\n\n const promise = doIntrospect(token).finally(() => {\n inflightIntrospects.delete(token);\n });\n inflightIntrospects.set(token, promise);\n return promise;\n }\n\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\n const params = new URLSearchParams({ token, client_id: clientId! });\n if (clientSecret) {\n params.set(\"client_secret\", clientSecret);\n }\n\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\n let data: IntrospectResponse | null = null;\n for (let attempt = 0; ; attempt++) {\n if (attempt > 0) {\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\n }\n let response: Response;\n try {\n response = await fetch(introspectionEndpoint!, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: params,\n signal: AbortSignal.timeout(introspectTimeoutMs),\n });\n } catch {\n if (attempt < introspectRetries) continue;\n return null;\n }\n if (!response.ok) {\n if (response.status >= 500 && attempt < introspectRetries) continue;\n return null;\n }\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\n try {\n data = (await response.json()) as IntrospectResponse;\n } catch {\n if (attempt < introspectRetries) continue;\n return null;\n }\n break;\n }\n if (!data) return null;\n\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\n if (data.active !== true) {\n if (introspectNegativeCacheTtl > 0) {\n introspectCache.set(token, { active: false, payload: null }, {\n ttl: introspectNegativeCacheTtl,\n });\n }\n return { active: false };\n }\n\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\n if (!matchesAudience(data)) {\n if (introspectNegativeCacheTtl > 0) {\n introspectCache.set(token, { active: false, payload: null }, {\n ttl: introspectNegativeCacheTtl,\n });\n }\n return { active: false };\n }\n\n let payload: VerifiedTokenPayload | null = null;\n if (data.sub) {\n payload = {\n sub: data.sub,\n aud: audience,\n iss: issuer,\n client_id: data.client_id,\n scope: data.scope,\n exp: data.exp,\n } as VerifiedTokenPayload;\n }\n\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\n // 已过期/无剩余有效期时不缓存。\n let ttl = introspectCacheTtl;\n if (typeof data.exp === \"number\") {\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\n }\n if (ttl > 0) {\n introspectCache.set(token, { active: true, payload }, { ttl });\n }\n return data;\n }\n\n /**\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\n * (session-init / getSessionUser 等)一律读 payload.sub。\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\n */\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\n const p = payload as VerifiedTokenPayload & { id?: string };\n if (!p.sub && typeof p.id === \"string\" && p.id) {\n p.sub = p.id;\n }\n return p.sub ? p : null;\n }\n\n /**\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\n */\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\n if (!accessTokenSecret) return null;\n\n try {\n const secret = new TextEncoder().encode(accessTokenSecret);\n const { payload } = await jwtVerify(token, secret, {\n issuer,\n audience,\n algorithms: [\"HS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n\n if ((payload as { type?: string }).type !== \"access_token\") {\n return null;\n }\n\n return normalizeLocalPayload(payload);\n } catch {\n return null;\n }\n }\n\n /**\n * RS256 本地验证(优先直接公钥,其次 JWKS 远程获取)\n */\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\n // 检查是否有 RS256 验证能力\n const hasPublicKey = Boolean(accessTokenPublicKey);\n const hasJwks = Boolean(jwksUri);\n if (!hasPublicKey && !hasJwks) return null;\n\n try {\n // 优先使用直接公钥\n const directKey = await getRS256PublicKey();\n if (directKey) {\n const { payload } = await jwtVerify(token, directKey, {\n issuer,\n audience,\n algorithms: [\"RS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n if ((payload as { type?: string }).type !== \"access_token\") return null;\n return normalizeLocalPayload(payload);\n }\n\n // 回退到 JWKS 远程获取\n const jwks = getJwksKeySet();\n if (jwks) {\n const { payload } = await jwtVerify(token, jwks, {\n issuer,\n audience,\n algorithms: [\"RS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n if ((payload as { type?: string }).type !== \"access_token\") return null;\n return normalizeLocalPayload(payload);\n }\n\n return null;\n } catch {\n return null;\n }\n }\n\n return {\n /**\n * 验证 Access Token\n *\n * 验证顺序:\n * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证\n * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证\n * 3. 若上述均失败/未配置,调用 Introspection 端点\n *\n * @returns token payload 或者 null(验证失败)\n */\n async verify(token: string): Promise<VerifiedTokenPayload | null> {\n // 1. HS256 本地验证(优先速度,适用于共享 secret 的内部服务)\n if (accessTokenSecret) {\n const local = await verifyLocally(token);\n if (local) return local;\n }\n\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\n const rs256Result = await verifyWithRS256(token);\n if (rs256Result) return rs256Result;\n\n // 3. Introspection 验证(兜底方案)\n const result = await introspect(token);\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\n if (result?.active !== true || !result.sub) return null;\n\n return {\n sub: result.sub,\n aud: audience,\n iss: issuer,\n client_id: result.client_id,\n scope: result.scope,\n exp: result.exp,\n } as VerifiedTokenPayload;\n },\n\n /**\n * 直接调用 Introspection 端点\n */\n introspect,\n\n /**\n * 清除指定 token 的 Introspection 缓存\n */\n invalidateCache(token: string): void {\n introspectCache.delete(token);\n },\n\n /**\n * 验证 Logout Token(Back-Channel Logout)\n *\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\n * 验证要求(OIDC Back-Channel Logout 1.0):\n * 1. type === \"logout_token\"\n * 2. iss 匹配配置的 issuer\n * 3. aud 包含当前 client_id\n * 4. exp 存在(规范要求)\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\n *\n * @returns LogoutTokenPayload 或 null(验证失败)\n */\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\n const validateLogoutPayload = async (\n payload: Record<string, unknown>\n ): Promise<LogoutTokenPayload | null> => {\n if ((payload as { type?: string }).type !== \"logout_token\") {\n return null;\n }\n // 规范要求 logout_token 必须携带 exp\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\n return null;\n }\n const events = (payload as { events?: Record<string, unknown> }).events;\n const logoutEvent =\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\n // 事件值必须是对象(通常为空对象 {})\n if (\n !logoutEvent ||\n typeof logoutEvent !== \"object\" ||\n Array.isArray(logoutEvent)\n ) {\n return null;\n }\n const jti = (payload as { jti?: string }).jti;\n if (!jti || typeof jti !== \"string\") {\n return null;\n }\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\n // 否则无法定位要终止的会话/用户,按验证失败处理\n const sub = (payload as { sub?: unknown }).sub;\n const sid = (payload as { sid?: unknown }).sid;\n const hasSub = typeof sub === \"string\" && sub.length > 0;\n const hasSid = typeof sid === \"string\" && sid.length > 0;\n if (!hasSub && !hasSid) {\n return null;\n }\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\n const iss = (payload as { iss?: string }).iss || issuer;\n const jtiKey = `${iss}:${jti}`;\n if (logoutJtiStore) {\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\n if (!firstUse) return null;\n } else {\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\n // 多实例部署请补充 addIfAbsent 实现\n if (await logoutJtiStore.has(jtiKey)) {\n return null;\n }\n await logoutJtiStore.add(jtiKey, 10 * 60);\n }\n } else {\n if (processedLogoutJtis.has(jtiKey)) {\n return null;\n }\n processedLogoutJtis.set(jtiKey, Date.now());\n }\n return payload as unknown as LogoutTokenPayload;\n };\n\n // 按 JWT header alg 分发,避免跨算法静默回退\n let alg: string | undefined;\n try {\n alg = decodeProtectedHeader(token).alg;\n } catch {\n return null;\n }\n\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\n // 无任何可用公钥时直接失败,不回退 HS256。\n if (alg === \"RS256\") {\n const directKey = await getLogoutRS256PublicKey();\n if (directKey) {\n try {\n const { payload } = await jwtVerify(token, directKey, {\n issuer,\n audience,\n algorithms: [\"RS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\n } catch {\n return null;\n }\n }\n\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\n // 与 verify() 一致按验证失败处理,而不是异常穿透\n let jwks: ReturnType<typeof getJwksKeySet> = null;\n try {\n jwks = getJwksKeySet();\n } catch {\n return null;\n }\n if (jwks) {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer,\n audience,\n algorithms: [\"RS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\n } catch {\n return null;\n }\n }\n\n return null;\n }\n\n // 2. HS256:使用 logoutTokenSecret(显式配置)或 accessTokenSecret 本地验证\n if (alg === \"HS256\") {\n const secret = logoutTokenSecret || accessTokenSecret;\n if (!secret) return null;\n\n try {\n const key = new TextEncoder().encode(secret);\n const { payload } = await jwtVerify(token, key, {\n issuer,\n audience,\n algorithms: [\"HS256\"],\n clockTolerance: clockToleranceSeconds,\n });\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\n } catch {\n return null;\n }\n }\n\n return null;\n },\n };\n}\n\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\nexport interface SsoMiddlewareRequest {\n headers?: Record<string, string | undefined>;\n /** 验证通过后由中间件挂载的用户信息 */\n user?: VerifiedTokenPayload;\n [key: string]: unknown;\n}\n\n/** 框架无关的最小中间件响应类型 */\nexport interface SsoMiddlewareResponse {\n status?: (code: number) => { json: (body: unknown) => unknown };\n [key: string]: unknown;\n}\n\n/**\n * Express/Next.js 兼容中间件\n *\n * 使用示例(Express):\n * ```typescript\n * app.use(ssoMiddleware({\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\n * clientId: \"advisor\",\n * clientSecret: \"SECRET\",\n * audience: \"advisor\",\n * }));\n * ```\n *\n * 使用示例(Next.js Route Handler):\n * ```typescript\n * const verifier = createTokenVerifier({ ... });\n * export async function GET(request: NextRequest) {\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\n * const payload = token ? await verifier.verify(token) : null;\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n * // ...\n * }\n * ```\n */\nexport function ssoMiddleware(options: SsoVerifierOptions) {\n const verifier = createTokenVerifier(options);\n\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\n\n if (!token) {\n if (res.status) {\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\n return;\n }\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\n return;\n }\n\n const payload = await verifier.verify(token);\n if (!payload) {\n if (res.status) {\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\n return;\n }\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\n return;\n }\n\n // 挂载用户信息到请求对象\n req.user = payload;\n next();\n };\n}\n\n/**\n * 创建 Logout Token 专用验证器\n *\n * 适用于仅需处理 backchannel logout 的子项目。\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\n *\n * 使用示例:\n * ```typescript\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\n *\n * const logoutVerifier = createLogoutTokenVerifier({\n * audience: \"advisor\",\n * issuer: \"https://nihplod.cn\",\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\n * });\n *\n * // 在 backchannel logout 端点中:\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\n * const token = req.body.logout_token;\n * const payload = await logoutVerifier.verify(token);\n * if (payload) {\n * await clearUserSession(payload.sub);\n * res.status(200).end();\n * } else {\n * res.status(400).end();\n * }\n * });\n * ```\n */\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\n const verifier = createTokenVerifier(options);\n\n return {\n /**\n * 验证 logout_token\n */\n async verify(token: string): Promise<LogoutTokenPayload | null> {\n return verifier.verifyLogoutToken(token);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-verify\r\n *\r\n * NIHPLOD 一网通 SSO Token 验证工具包\r\n *\r\n * 供子项目在后端验证主站签发的 Access Token。\r\n *\r\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\r\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\r\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\r\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\r\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\r\n *\r\n * 安装:npm install @nihplod/sso-verify\r\n *\r\n * 使用:\r\n * ```typescript\r\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\r\n * const verifier = createTokenVerifier({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"YOUR_CLIENT_SECRET\",\r\n * audience: \"advisor\",\r\n * });\r\n *\r\n * const payload = await verifier.verify(token);\r\n * if (payload) {\r\n * console.log(payload.sub); // 用户 ID\r\n * }\r\n * ```\r\n */\r\n\r\nimport {\r\n jwtVerify,\r\n importSPKI,\r\n createRemoteJWKSet,\r\n decodeProtectedHeader,\r\n type KeyLike,\r\n type JWTPayload,\r\n} from \"jose\";\r\nimport { LRUCache } from \"lru-cache\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoVerifierOptions {\r\n /** 期望的 audience(对应子项目的 client_id) */\r\n audience: string;\r\n\r\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\r\n issuer?: string;\r\n\r\n /**\r\n * Token Introspection 端点 URL。\r\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\r\n */\r\n introspectionEndpoint?: string;\r\n\r\n /** 子项目的 client_id(用于 introspection) */\r\n clientId?: string;\r\n\r\n /** 子项目的 client_secret(用于 introspection) */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 共享的 Access Token Secret(可选)。\r\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\r\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\r\n */\r\n accessTokenSecret?: string;\r\n\r\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\r\n introspectCacheTtl?: number;\r\n\r\n /**\r\n * Introspection 请求超时时间(毫秒),默认 10 秒。\r\n * 超时按验证失败处理(返回 null)。\r\n */\r\n introspectTimeoutMs?: number;\r\n\r\n /**\r\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\r\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\r\n * 设为 0 表示不缓存 active:false 结果。\r\n */\r\n introspectNegativeCacheTtl?: number;\r\n\r\n /**\r\n * Access Token RS256 公钥(PEM 格式,可选)。\r\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\r\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\r\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\r\n * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS\r\n * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。\r\n */\r\n accessTokenPublicKey?: string;\r\n\r\n /**\r\n * JWKS 端点 URL(可选)。\r\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\r\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;\r\n * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。\r\n */\r\n jwksUri?: string;\r\n\r\n /**\r\n * Logout Token Secret(可选)。\r\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\r\n * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token\r\n * 验证将失败(返回 null)。\r\n */\r\n logoutTokenSecret?: string;\r\n\r\n /**\r\n * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。\r\n *\r\n * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。\r\n * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),\r\n * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。\r\n * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。\r\n *\r\n * 开启后会在两处输出告警(console.warn):\r\n * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置\r\n * logoutTokenSecret)立即提示一次;\r\n * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发\r\n * (一次性告警)。\r\n */\r\n allowLegacyAccessTokenSecretFallback?: boolean;\r\n\r\n /**\r\n * Logout Token RS256 公钥(PEM 格式,可选)。\r\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\r\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\r\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;\r\n * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。\r\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\r\n * 不会静默回退到 HS256。\r\n */\r\n logoutTokenPublicKey?: string;\r\n\r\n /**\r\n * Introspection 请求失败重试次数,默认 1。\r\n * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。\r\n * 设为 0 表示不重试。\r\n */\r\n introspectRetries?: number;\r\n\r\n /**\r\n * JWT 时钟偏移容忍(秒),默认 60。\r\n * 应用于所有本地验签路径(HS256/RS256/logout token),\r\n * 用于容忍子项目与主站之间的时钟偏差。\r\n */\r\n clockToleranceSeconds?: number;\r\n\r\n /**\r\n * Logout Token jti 外部存储(可选)。\r\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\r\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\r\n */\r\n logoutJtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * Introspection audience 严格模式(默认 true,fail-closed)。\r\n *\r\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\r\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\r\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\r\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\r\n */\r\n strictAudience?: boolean;\r\n\r\n /**\r\n * 验证模式(默认 \"auto\"):\r\n * - \"auto\":配置了 introspectionEndpoint + clientId 时使用 Introspection(可实时感知撤销);\r\n * 未配置时回退本地 JWT 验签。\r\n * - \"introspection\":强制 Introspection;未配置端点时回退本地验签。\r\n * - \"local\":仅本地 HS256/RS256 验签(不感知撤销,仅建议非敏感、高吞吐场景)。\r\n * - \"hybrid\":先本地验签,失败后再 Introspection(旧版行为,性能优先)。\r\n *\r\n * 涉及账户状态/授权撤销的鉴权建议使用 \"auto\" 或 \"introspection\"。\r\n */\r\n verificationMode?: \"auto\" | \"introspection\" | \"local\" | \"hybrid\";\r\n\r\n /**\r\n * 是否允许 M2M(client_credentials)token 通过用户态校验(默认 false)。\r\n * M2M token 的 sub 为 `client:<client_id>`,不是用户 ID;若业务确实需要\r\n * 校验 M2M token,请显式设为 true 并自行区分 client_type。\r\n */\r\n allowM2mTokens?: boolean;\r\n}\r\n\r\n/**\r\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\r\n *\r\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\r\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\r\n */\r\nexport interface LogoutJtiStore {\r\n /** 判断 jti 是否已处理过 */\r\n has(key: string): boolean | Promise<boolean>;\r\n /** 记录已处理的 jti,ttlSeconds 后过期 */\r\n add(key: string, ttlSeconds: number): void | Promise<void>;\r\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\r\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\r\n}\r\n\r\nexport interface VerifiedTokenPayload extends JWTPayload {\r\n sub: string;\r\n aud: string;\r\n iss: string;\r\n client_id?: string;\r\n scope?: string;\r\n phone?: string;\r\n /** 身份类型:m2m = client_credentials,user = 用户授权码流程 */\r\n client_type?: \"m2m\" | \"user\";\r\n /** OAuthSession ID;携带 sid 的 token 可被会话级撤销 */\r\n sid?: string;\r\n /** 原始认证时间(Unix 秒),供 max_age 判断 */\r\n auth_time?: number;\r\n /** DPoP 绑定(RFC 9449 cnf.jkt) */\r\n cnf?: { jkt?: string };\r\n /** introspection 返回的 token 类型(Bearer / DPoP) */\r\n token_type?: \"Bearer\" | \"DPoP\";\r\n}\r\n\r\n/**\r\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\r\n *\r\n * 主站签发 logout_token 时包含以下字段:\r\n * - iss: 主站 issuer\r\n * - aud: 目标 client_id\r\n * - sub: 用户 ID(与 sid 至少其一)\r\n * - iat: 签发时间\r\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\r\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\r\n */\r\nexport interface LogoutTokenPayload {\r\n /** JWT type,logout_token 固定值为 \"logout_token\" */\r\n type: \"logout_token\";\r\n /** 签发者 */\r\n iss: string;\r\n /** 目标 audience */\r\n aud: string;\r\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\r\n sub?: string;\r\n /** 签发时间(UNIX timestamp) */\r\n iat: number;\r\n /** JWT ID(唯一标识,防重放) */\r\n jti: string;\r\n /** Backchannel logout 事件声明 */\r\n events: {\r\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\r\n };\r\n /** sid (Session ID),可选 */\r\n sid?: string;\r\n}\r\n\r\ninterface IntrospectResponse {\r\n active: boolean;\r\n sub?: string;\r\n aud?: string | string[];\r\n client_id?: string;\r\n scope?: string;\r\n exp?: number;\r\n token_type?: string;\r\n client_type?: \"m2m\" | \"user\";\r\n sid?: string;\r\n auth_time?: number;\r\n cnf?: { jkt?: string };\r\n [key: string]: unknown;\r\n}\r\n\r\n// ============================================\r\n// Introspection 结果缓存\r\n// ============================================\r\n\r\ninterface IntrospectCacheEntry {\r\n active: boolean;\r\n payload: VerifiedTokenPayload | null;\r\n}\r\n\r\nfunction createIntrospectCache(ttlMs: number) {\r\n return new LRUCache<string, IntrospectCacheEntry>({\r\n max: 10000,\r\n ttl: ttlMs,\r\n });\r\n}\r\n\r\n// ============================================\r\n// Logout Token jti 重放防护缓存\r\n// ============================================\r\n\r\n/**\r\n * 已处理的 logout_token jti 缓存。\r\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\r\n */\r\nconst processedLogoutJtis = new LRUCache<string, number>({\r\n max: 10000,\r\n ttl: 10 * 60 * 1000,\r\n});\r\n\r\n// 废弃的 HS256 logout_token 回退(accessTokenSecret 代验)告警文案,\r\n// 构造时与首次实际使用时复用同一条消息\r\nconst LEGACY_LOGOUT_SECRET_FALLBACK_WARNING =\r\n \"[sso-verify] 正在使用 accessTokenSecret 验证 HS256 logout_token(废弃路径):\" +\r\n \"共享 access secret 的服务可伪造任意用户登出。\" +\r\n \"请改用 logoutTokenSecret,或配置 logoutTokenPublicKey/jwksUri 走 RS256。\";\r\n\r\n// 非生产环境下 http:// 非 loopback 端点的告警去重(每个端点名每进程只提示一次)\r\nconst warnedInsecureEndpoints = new Set<string>();\r\n\r\n// ============================================\r\n// Token Verifier\r\n// ============================================\r\n\r\nexport function createTokenVerifier(options: SsoVerifierOptions) {\r\n const {\r\n audience,\r\n issuer = \"https://nihplod.cn\",\r\n introspectionEndpoint,\r\n clientId,\r\n clientSecret,\r\n accessTokenSecret,\r\n accessTokenPublicKey,\r\n jwksUri,\r\n logoutTokenSecret,\r\n logoutTokenPublicKey,\r\n allowLegacyAccessTokenSecretFallback = false,\r\n introspectCacheTtl = 30 * 1000,\r\n introspectTimeoutMs = 10 * 1000,\r\n introspectNegativeCacheTtl = 5 * 1000,\r\n introspectRetries = 1,\r\n clockToleranceSeconds = 60,\r\n logoutJtiStore,\r\n strictAudience = true,\r\n verificationMode = \"auto\",\r\n allowM2mTokens = false,\r\n } = options;\r\n\r\n // 一次性告警标志:使用废弃的 accessTokenSecret 回退验证 logout_token 时提示迁移\r\n let warnedLegacyLogoutSecret = false;\r\n\r\n // 构造时即提示:显式开启回退且该路径实际可生效(有 accessTokenSecret、无 logoutTokenSecret)\r\n if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n\r\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\r\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\r\n // 非生产环境下 http:// 非 loopback 端点输出一次性告警(不阻断本地联调)。\r\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\r\n if (!value) return;\r\n let url: URL;\r\n try {\r\n url = new URL(value);\r\n } catch {\r\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\r\n }\r\n if (url.protocol === \"https:\") return;\r\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\r\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\r\n if (url.protocol === \"http:\" && isLoopback) return;\r\n if (process.env.NODE_ENV !== \"production\") {\r\n if (url.protocol === \"http:\" && !warnedInsecureEndpoints.has(name)) {\r\n warnedInsecureEndpoints.add(name);\r\n console.warn(\r\n `[sso-verify] ${name} 使用非加密的 http:// 且非 loopback 地址;` +\r\n \"生产环境(NODE_ENV=production)将直接抛错\"\r\n );\r\n }\r\n return;\r\n }\r\n throw new Error(\r\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\r\n \"避免凭证/令牌经明文传输\"\r\n );\r\n };\r\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\r\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\r\n\r\n const introspectCache = createIntrospectCache(introspectCacheTtl);\r\n\r\n // RS256 JWKS 远程密钥集(延迟初始化)\r\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\r\n function getJwksKeySet() {\r\n if (!jwksUri) return null;\r\n if (!_jwksKeySet) {\r\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\r\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\r\n });\r\n }\r\n return _jwksKeySet;\r\n }\r\n\r\n // RS256 公钥(延迟导入)\r\n let _rs256PublicKey: KeyLike | null = null;\r\n let _rs256KeyInitialized = false;\r\n async function getRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_rs256KeyInitialized) {\r\n _rs256KeyInitialized = true;\r\n if (!accessTokenPublicKey) {\r\n _rs256PublicKey = null;\r\n } else {\r\n try {\r\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\r\n } catch {\r\n _rs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _rs256PublicKey;\r\n }\r\n\r\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\r\n let _logoutRs256PublicKey: KeyLike | null = null;\r\n let _logoutRs256KeyInitialized = false;\r\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_logoutRs256KeyInitialized) {\r\n _logoutRs256KeyInitialized = true;\r\n if (!logoutTokenPublicKey) {\r\n _logoutRs256PublicKey = null;\r\n } else {\r\n try {\r\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\r\n } catch {\r\n _logoutRs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _logoutRs256PublicKey;\r\n }\r\n\r\n /**\r\n * Introspection 响应的 aud 归属校验(防 confused deputy):\r\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\r\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\r\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\r\n * 默认 strictAudience=true 时 fail-closed 拒绝。\r\n */\r\n function matchesAudience(data: IntrospectResponse): boolean {\r\n const aud = data.aud;\r\n if (typeof aud === \"string\") return aud === audience;\r\n if (Array.isArray(aud)) return aud.includes(audience);\r\n if (typeof data.client_id === \"string\") return data.client_id === audience;\r\n return !strictAudience;\r\n }\r\n\r\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\r\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\r\n\r\n /**\r\n * 调用主站 Introspection 端点验证 token\r\n *\r\n * Public Client 可省略 client_secret,仅传 client_id。\r\n * Confidential Client 建议传入 client_secret 以通过端点认证。\r\n */\r\n async function introspect(token: string): Promise<IntrospectResponse | null> {\r\n if (!introspectionEndpoint || !clientId) {\r\n return null;\r\n }\r\n\r\n const cached = introspectCache.get(token);\r\n if (cached) {\r\n return { active: cached.active, ...(cached.payload || {}) };\r\n }\r\n\r\n const inflight = inflightIntrospects.get(token);\r\n if (inflight) {\r\n return inflight;\r\n }\r\n\r\n const promise = doIntrospect(token).finally(() => {\r\n inflightIntrospects.delete(token);\r\n });\r\n inflightIntrospects.set(token, promise);\r\n return promise;\r\n }\r\n\r\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\r\n const params = new URLSearchParams({ token, client_id: clientId! });\r\n if (clientSecret) {\r\n params.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\r\n let data: IntrospectResponse | null = null;\r\n for (let attempt = 0; ; attempt++) {\r\n if (attempt > 0) {\r\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\r\n }\r\n let response: Response;\r\n try {\r\n response = await fetch(introspectionEndpoint!, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: params,\r\n signal: AbortSignal.timeout(introspectTimeoutMs),\r\n });\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n if (!response.ok) {\r\n // 5xx 与 429(限流)可重试;429 通常需要更长窗口,但仍先短退避尝试一次\r\n if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {\r\n continue;\r\n }\r\n return null;\r\n }\r\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\r\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\r\n try {\r\n data = (await response.json()) as IntrospectResponse;\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n break;\r\n }\r\n if (!data) return null;\r\n\r\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\r\n if (data.active !== true) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\r\n if (!matchesAudience(data)) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n let payload: VerifiedTokenPayload | null = null;\r\n if (data.sub) {\r\n payload = {\r\n sub: data.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: data.client_id,\r\n scope: data.scope,\r\n exp: data.exp,\r\n ...(data.client_type ? { client_type: data.client_type } : {}),\r\n ...(data.sid ? { sid: data.sid } : {}),\r\n ...(typeof data.auth_time === \"number\" ? { auth_time: data.auth_time } : {}),\r\n ...(data.cnf ? { cnf: data.cnf } : {}),\r\n } as VerifiedTokenPayload;\r\n }\r\n\r\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\r\n // 已过期/无剩余有效期时不缓存。\r\n let ttl = introspectCacheTtl;\r\n if (typeof data.exp === \"number\") {\r\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\r\n }\r\n if (ttl > 0) {\r\n introspectCache.set(token, { active: true, payload }, { ttl });\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\r\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\r\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\r\n * (session-init / getSessionUser 等)一律读 payload.sub。\r\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\r\n */\r\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\r\n const p = payload as VerifiedTokenPayload & { id?: string };\r\n if (!p.sub && typeof p.id === \"string\" && p.id) {\r\n p.sub = p.id;\r\n }\r\n return p.sub ? p : null;\r\n }\r\n\r\n /**\r\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\r\n */\r\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\r\n if (!accessTokenSecret) return null;\r\n\r\n try {\r\n const secret = new TextEncoder().encode(accessTokenSecret);\r\n const { payload } = await jwtVerify(token, secret, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n\r\n if ((payload as { type?: string }).type !== \"access_token\") {\r\n return null;\r\n }\r\n\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * RS256 本地验证(优先直接公钥,验证失败回退 JWKS 远程获取)\r\n */\r\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 检查是否有 RS256 验证能力\r\n const hasPublicKey = Boolean(accessTokenPublicKey);\r\n const hasJwks = Boolean(jwksUri);\r\n if (!hasPublicKey && !hasJwks) return null;\r\n\r\n try {\r\n // 优先使用直接公钥;验证失败(如主站轮换密钥后本地公钥过期)\r\n // 时回退 JWKS,而不是直接判定失败\r\n const directKey = await getRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // 回退到 JWKS 远程获取\r\n const jwks = getJwksKeySet();\r\n if (jwks) {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n }\r\n\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n const hasIntrospection = Boolean(introspectionEndpoint && clientId);\r\n\r\n /**\r\n * M2M token 默认拒绝用户态校验:其 sub 是 `client:<id>`,不是用户 ID。\r\n * 需要校验 M2M token 的服务显式设置 allowM2mTokens: true,并自行区分 client_type。\r\n */\r\n const rejectM2mPayload = (payload: VerifiedTokenPayload | null): VerifiedTokenPayload | null => {\r\n if (!payload || allowM2mTokens) return payload;\r\n const isM2m = payload.client_type === \"m2m\" || payload.sub.startsWith(\"client:\");\r\n return isM2m ? null : payload;\r\n };\r\n\r\n const verifyLocallyBoth = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n // 1. HS256 本地验证(适用于共享 secret 的内部服务)\r\n if (accessTokenSecret) {\r\n const local = await verifyLocally(rawToken);\r\n if (local) return local;\r\n }\r\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\r\n return verifyWithRS256(rawToken);\r\n };\r\n\r\n const verifyViaIntrospection = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n const result = await introspect(rawToken);\r\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\r\n if (result?.active !== true || !result.sub) return null;\r\n return {\r\n sub: result.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: result.client_id,\r\n scope: result.scope,\r\n exp: result.exp,\r\n ...(result.client_type ? { client_type: result.client_type } : {}),\r\n ...(result.sid ? { sid: result.sid } : {}),\r\n ...(typeof result.auth_time === \"number\" ? { auth_time: result.auth_time } : {}),\r\n ...(result.cnf ? { cnf: result.cnf } : {}),\r\n ...(result.token_type ? { token_type: result.token_type } : {}),\r\n } as VerifiedTokenPayload;\r\n };\r\n\r\n return {\r\n /**\r\n * 验证 Access Token\r\n *\r\n * 模式由 verificationMode 控制(默认 \"auto\"):\r\n * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。\r\n * - introspection:强制 Introspection(未配置端点时回退本地)。\r\n * - local:仅本地验签(不感知撤销)。\r\n * - hybrid:本地优先,失败后 Introspection(旧版行为)。\r\n *\r\n * @returns token payload 或者 null(验证失败)\r\n */\r\n async verify(rawToken: string): Promise<VerifiedTokenPayload | null> {\r\n const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));\r\n const runIntrospection = async () =>\r\n rejectM2mPayload(await verifyViaIntrospection(rawToken));\r\n\r\n if (verificationMode === \"local\") {\r\n return await runLocal();\r\n }\r\n if (verificationMode === \"introspection\") {\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n }\r\n if (verificationMode === \"hybrid\") {\r\n const local = await runLocal();\r\n if (local) return local;\r\n return hasIntrospection ? await runIntrospection() : null;\r\n }\r\n // auto:Introspection 配置存在时默认走 Introspection,避免本地验签绕过撤销。\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n },\r\n\r\n /**\r\n * 直接调用 Introspection 端点\r\n */\r\n introspect,\r\n\r\n /**\r\n * 清除指定 token 的 Introspection 缓存\r\n */\r\n invalidateCache(token: string): void {\r\n introspectCache.delete(token);\r\n },\r\n\r\n /**\r\n * 验证 Logout Token(Back-Channel Logout)\r\n *\r\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\r\n * 验证要求(OIDC Back-Channel Logout 1.0):\r\n * 1. type === \"logout_token\"\r\n * 2. iss 匹配配置的 issuer\r\n * 3. aud 包含当前 client_id\r\n * 4. exp 存在(规范要求)\r\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\r\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\r\n *\r\n * @returns LogoutTokenPayload 或 null(验证失败)\r\n */\r\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\r\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\r\n const validateLogoutPayload = async (\r\n payload: Record<string, unknown>\r\n ): Promise<LogoutTokenPayload | null> => {\r\n if ((payload as { type?: string }).type !== \"logout_token\") {\r\n return null;\r\n }\r\n // 规范要求 logout_token 必须携带 exp / iat,且不得携带 nonce\r\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\r\n return null;\r\n }\r\n if (typeof (payload as { iat?: unknown }).iat !== \"number\") {\r\n return null;\r\n }\r\n if ((payload as { nonce?: unknown }).nonce !== undefined) {\r\n return null;\r\n }\r\n const events = (payload as { events?: Record<string, unknown> }).events;\r\n const logoutEvent =\r\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\r\n // 事件值必须是对象(通常为空对象 {})\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n return null;\r\n }\r\n const jti = (payload as { jti?: string }).jti;\r\n if (!jti || typeof jti !== \"string\") {\r\n return null;\r\n }\r\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\r\n // 否则无法定位要终止的会话/用户,按验证失败处理\r\n const sub = (payload as { sub?: unknown }).sub;\r\n const sid = (payload as { sid?: unknown }).sid;\r\n const hasSub = typeof sub === \"string\" && sub.length > 0;\r\n const hasSid = typeof sid === \"string\" && sid.length > 0;\r\n if (!hasSub && !hasSid) {\r\n return null;\r\n }\r\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\r\n const iss = (payload as { iss?: string }).iss || issuer;\r\n const jtiKey = `${iss}:${jti}`;\r\n if (logoutJtiStore) {\r\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\r\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\r\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\r\n if (!firstUse) return null;\r\n } else {\r\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\r\n // 多实例部署请补充 addIfAbsent 实现\r\n if (await logoutJtiStore.has(jtiKey)) {\r\n return null;\r\n }\r\n await logoutJtiStore.add(jtiKey, 10 * 60);\r\n }\r\n } else {\r\n if (processedLogoutJtis.has(jtiKey)) {\r\n return null;\r\n }\r\n processedLogoutJtis.set(jtiKey, Date.now());\r\n }\r\n return payload as unknown as LogoutTokenPayload;\r\n };\r\n\r\n // 按 JWT header alg 分发,避免跨算法静默回退\r\n let alg: string | undefined;\r\n try {\r\n alg = decodeProtectedHeader(token).alg;\r\n } catch {\r\n return null;\r\n }\r\n\r\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\r\n // 直接公钥验证失败(如密钥轮换后本地公钥过期)时回退 JWKS;\r\n // 无任何可用公钥时直接失败,不回退 HS256。\r\n if (alg === \"RS256\") {\r\n const directKey = await getLogoutRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\r\n // 与 verify() 一致按验证失败处理,而不是异常穿透\r\n let jwks: ReturnType<typeof getJwksKeySet> = null;\r\n try {\r\n jwks = getJwksKeySet();\r\n } catch {\r\n return null;\r\n }\r\n if (jwks) {\r\n try {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n // 2. HS256:使用 logoutTokenSecret(专用密钥);回退 accessTokenSecret 默认关闭\r\n if (alg === \"HS256\") {\r\n const secret =\r\n logoutTokenSecret ||\r\n (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : undefined);\r\n if (!secret) return null;\r\n if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {\r\n if (!warnedLegacyLogoutSecret) {\r\n warnedLegacyLogoutSecret = true;\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n }\r\n\r\n try {\r\n const key = new TextEncoder().encode(secret);\r\n const { payload } = await jwtVerify(token, key, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n },\r\n };\r\n}\r\n\r\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\r\nexport interface SsoMiddlewareRequest {\r\n headers?: Record<string, string | undefined>;\r\n /** 验证通过后由中间件挂载的用户信息 */\r\n user?: VerifiedTokenPayload;\r\n [key: string]: unknown;\r\n}\r\n\r\n/** 框架无关的最小中间件响应类型 */\r\nexport interface SsoMiddlewareResponse {\r\n status?: (code: number) => { json: (body: unknown) => unknown };\r\n [key: string]: unknown;\r\n}\r\n\r\n/**\r\n * Express/Next.js 兼容中间件\r\n *\r\n * 使用示例(Express):\r\n * ```typescript\r\n * app.use(ssoMiddleware({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"SECRET\",\r\n * audience: \"advisor\",\r\n * }));\r\n * ```\r\n *\r\n * 使用示例(Next.js Route Handler):\r\n * ```typescript\r\n * const verifier = createTokenVerifier({ ... });\r\n * export async function GET(request: NextRequest) {\r\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\r\n * const payload = token ? await verifier.verify(token) : null;\r\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\r\n * // ...\r\n * }\r\n * ```\r\n */\r\nexport function ssoMiddleware(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\r\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\r\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\r\n\r\n if (!token) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\r\n return;\r\n }\r\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\r\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\r\n return;\r\n }\r\n\r\n const payload = await verifier.verify(token);\r\n if (!payload) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\r\n return;\r\n }\r\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\r\n return;\r\n }\r\n\r\n // 挂载用户信息到请求对象\r\n req.user = payload;\r\n next();\r\n };\r\n}\r\n\r\n/**\r\n * 创建 Logout Token 专用验证器\r\n *\r\n * 适用于仅需处理 backchannel logout 的子项目。\r\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\r\n *\r\n * 使用示例:\r\n * ```typescript\r\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\r\n *\r\n * const logoutVerifier = createLogoutTokenVerifier({\r\n * audience: \"advisor\",\r\n * issuer: \"https://nihplod.cn\",\r\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\r\n * });\r\n *\r\n * // 在 backchannel logout 端点中:\r\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\r\n * const token = req.body.logout_token;\r\n * const payload = await logoutVerifier.verify(token);\r\n * if (payload) {\r\n * await clearUserSession(payload.sub);\r\n * res.status(200).end();\r\n * } else {\r\n * res.status(400).end();\r\n * }\r\n * });\r\n * ```\r\n */\r\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return {\r\n /**\r\n * 验证 logout_token\r\n */\r\n async verify(token: string): Promise<LogoutTokenPayload | null> {\r\n return verifier.verifyLogoutToken(token);\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
|