@nihplod/sso-verify 1.1.3 → 1.2.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 CHANGED
@@ -104,11 +104,11 @@ After successful verification, the token payload is attached to `req.user`.
104
104
  | `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
105
105
  | `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
106
106
  | `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
107
- | `strictAudience` | `false` | Reject introspection responses that carry neither `aud` nor `client_id` (fail-closed). **Recommended for production** |
107
+ | `strictAudience` | `true` | Reject introspection responses that carry neither `aud` nor `client_id` (fail-closed). Set `false` only for legacy third-party endpoints that return no audience fields |
108
108
 
109
109
  ### Revocation latency trade-off
110
110
 
111
- Introspection results are cached to reduce latency and load on the main site. This means a revoked access token may remain accepted locally until its cache entry expires. `active: true` results are cached for `introspectCacheTtl` (default 30s); `active: false` results use the much shorter `introspectNegativeCacheTtl` (default 5s), so a previously-invalid token that becomes valid (e.g. re-issued) converges quickly, while revocation of an already-cached token takes at most `introspectCacheTtl`. Lower `introspectCacheTtl` for stronger revocation guarantees at the cost of more introspection calls. Use `verifier.invalidateCache(token)` to evict a specific token immediately.
111
+ Introspection results are cached to reduce latency and load on the main site. This means a revoked access token may remain accepted locally until its cache entry expires. `active: true` results are cached for `introspectCacheTtl` (default 30s, additionally capped at the token's remaining `exp`); `active: false` results use the much shorter `introspectNegativeCacheTtl` (default 5s), so a previously-invalid token that becomes valid (e.g. re-issued) converges quickly, while revocation of an already-cached token takes at most `introspectCacheTtl`. Lower `introspectCacheTtl` for stronger revocation guarantees at the cost of more introspection calls. Use `verifier.invalidateCache(token)` to evict a specific token immediately.
112
112
 
113
113
  Concurrent `verify()` calls for the same token share a single in-flight introspection request, and failed requests (network errors / 5xx) are retried once by default — see `introspectRetries`.
114
114
 
@@ -118,9 +118,9 @@ When verifying via introspection, the response's audience binding is checked aga
118
118
 
119
119
  - If the response contains `aud` (string or array), it must include the configured `audience`.
120
120
  - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
121
- - If the endpoint returns neither field, the response is trusted as-is (legacy behavior); the main site's introspection endpoint always returns `client_id`, so tokens issued to other clients are rejected.
121
+ - If the endpoint returns neither field, the response is rejected by default (`strictAudience: true`, fail-closed). The main site's introspection endpoint always returns `client_id`, so tokens issued to other clients are always rejected. Set `strictAudience: false` only when integrating a legacy third-party endpoint that returns no audience fields (not recommended).
122
122
 
123
- > ⚠️ **Production recommendation: enable `strictAudience: true`.** In strict mode a response missing both `aud` and `client_id` is rejected (fail-closed) instead of trusted, eliminating the fail-open path entirely. The main site's introspection endpoint always returns `client_id`, so enabling it does not change behavior against the main site — only turn it off when integrating a third-party introspection endpoint that returns no audience fields.
123
+ > ⚠️ **Fail-closed by default.** A response missing both `aud` and `client_id` is rejected instead of trusted, eliminating the confused-deputy fail-open path. Only lower this when you fully trust the introspection endpoint and cannot change it.
124
124
 
125
125
  ### Logout token verification
126
126
 
@@ -140,10 +140,15 @@ const verifier = createTokenVerifier({
140
140
  logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
141
141
  logoutJtiStore: {
142
142
  // Both sync and async implementations are supported.
143
+ // addIfAbsent is strongly recommended: it must atomically SET NX EX and
144
+ // return true only when this call created the key (first use).
145
+ addIfAbsent: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds, "NX").then((r) => r === "OK"),
146
+ // has/add are the legacy fallback used only when addIfAbsent is not provided
147
+ // (non-atomic check-then-act; single-instance use only).
143
148
  has: (key) => redis.exists(key).then((n) => n > 0),
144
149
  add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
145
150
  },
146
151
  });
147
152
  ```
148
153
 
149
- `has(key)` reports whether a jti was already processed; `add(key, ttlSeconds)` records it with expiry (the verifier passes 600 seconds, matching the built-in cache).
154
+ `addIfAbsent(key, ttlSeconds)` atomically records the jti and returns whether this call was the first use (implement with Redis `SET key 1 EX ttl NX`). It prevents the TOCTOU race between `has` and `add` across instances. If it is not implemented, the verifier falls back to `has` + `add` (the verifier passes 600 seconds, matching the built-in cache) �?acceptable only for single-instance deployments.
package/dist/index.d.mts CHANGED
@@ -112,26 +112,28 @@ interface SsoVerifierOptions {
112
112
  */
113
113
  logoutJtiStore?: LogoutJtiStore;
114
114
  /**
115
- * Introspection audience 严格模式(默认 false,保持向后兼容)。
115
+ * Introspection audience 严格模式(默认 true,fail-closed)。
116
116
  *
117
- * 默认模式下,introspection 响应既无 aud 又无 client_id 时保持信任
118
- * (兼容不返回归属字段的端点)。设为 true 后:响应缺少归属字段即拒绝
119
- * (fail-closed),防止被 confused deputy 攻击利用。
120
- *
121
- * ⚠️ 生产环境强烈建议开启。主站 introspection 端点始终返回 client_id,
122
- * 开启后行为不变;仅在对接不返回归属字段的第三方端点时才需要保持 false。
117
+ * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝
118
+ * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。
119
+ * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;
120
+ * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
123
121
  */
124
122
  strictAudience?: boolean;
125
123
  }
126
124
  /**
127
125
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
128
- * has/add 均支持同步或异步返回。
126
+ *
127
+ * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下
128
+ * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。
129
129
  */
130
130
  interface LogoutJtiStore {
131
131
  /** 判断 jti 是否已处理过 */
132
132
  has(key: string): boolean | Promise<boolean>;
133
133
  /** 记录已处理的 jti,ttlSeconds 后过期 */
134
134
  add(key: string, ttlSeconds: number): void | Promise<void>;
135
+ /** 原子地"不存在则记录":返回 true 表示本次成功占用(首次使用),false 表示已存在 */
136
+ addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;
135
137
  }
136
138
  interface VerifiedTokenPayload extends JWTPayload {
137
139
  sub: string;
package/dist/index.d.ts CHANGED
@@ -112,26 +112,28 @@ interface SsoVerifierOptions {
112
112
  */
113
113
  logoutJtiStore?: LogoutJtiStore;
114
114
  /**
115
- * Introspection audience 严格模式(默认 false,保持向后兼容)。
115
+ * Introspection audience 严格模式(默认 true,fail-closed)。
116
116
  *
117
- * 默认模式下,introspection 响应既无 aud 又无 client_id 时保持信任
118
- * (兼容不返回归属字段的端点)。设为 true 后:响应缺少归属字段即拒绝
119
- * (fail-closed),防止被 confused deputy 攻击利用。
120
- *
121
- * ⚠️ 生产环境强烈建议开启。主站 introspection 端点始终返回 client_id,
122
- * 开启后行为不变;仅在对接不返回归属字段的第三方端点时才需要保持 false。
117
+ * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝
118
+ * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。
119
+ * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;
120
+ * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
123
121
  */
124
122
  strictAudience?: boolean;
125
123
  }
126
124
  /**
127
125
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
128
- * has/add 均支持同步或异步返回。
126
+ *
127
+ * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下
128
+ * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。
129
129
  */
130
130
  interface LogoutJtiStore {
131
131
  /** 判断 jti 是否已处理过 */
132
132
  has(key: string): boolean | Promise<boolean>;
133
133
  /** 记录已处理的 jti,ttlSeconds 后过期 */
134
134
  add(key: string, ttlSeconds: number): void | Promise<void>;
135
+ /** 原子地"不存在则记录":返回 true 表示本次成功占用(首次使用),false 表示已存在 */
136
+ addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;
135
137
  }
136
138
  interface VerifiedTokenPayload extends JWTPayload {
137
139
  sub: string;
package/dist/index.js CHANGED
@@ -55,8 +55,26 @@ function createTokenVerifier(options) {
55
55
  introspectRetries = 1,
56
56
  clockToleranceSeconds = 60,
57
57
  logoutJtiStore,
58
- strictAudience = false
58
+ strictAudience = true
59
59
  } = options;
60
+ const assertHttpsEndpoint = (value, name) => {
61
+ if (!value || process.env.NODE_ENV !== "production") return;
62
+ let url;
63
+ try {
64
+ url = new URL(value);
65
+ } catch {
66
+ throw new Error(`[sso-verify] ${name} \u4E0D\u662F\u5408\u6CD5 URL: ${value}`);
67
+ }
68
+ if (url.protocol === "https:") return;
69
+ const host = url.hostname.replace(/^\[|\]$/g, "");
70
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
71
+ if (url.protocol === "http:" && isLoopback) return;
72
+ throw new Error(
73
+ `[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
+ );
75
+ };
76
+ assertHttpsEndpoint(introspectionEndpoint, "introspectionEndpoint");
77
+ assertHttpsEndpoint(jwksUri, "jwksUri");
60
78
  const introspectCache = createIntrospectCache(introspectCacheTtl);
61
79
  let _jwksKeySet = null;
62
80
  function getJwksKeySet() {
@@ -155,11 +173,24 @@ function createTokenVerifier(options) {
155
173
  if (response.status >= 500 && attempt < introspectRetries) continue;
156
174
  return null;
157
175
  }
158
- data = await response.json();
176
+ try {
177
+ data = await response.json();
178
+ } catch {
179
+ if (attempt < introspectRetries) continue;
180
+ return null;
181
+ }
159
182
  break;
160
183
  }
161
184
  if (!data) return null;
162
- if (data.active && !matchesAudience(data)) {
185
+ if (data.active !== true) {
186
+ if (introspectNegativeCacheTtl > 0) {
187
+ introspectCache.set(token, { active: false, payload: null }, {
188
+ ttl: introspectNegativeCacheTtl
189
+ });
190
+ }
191
+ return { active: false };
192
+ }
193
+ if (!matchesAudience(data)) {
163
194
  if (introspectNegativeCacheTtl > 0) {
164
195
  introspectCache.set(token, { active: false, payload: null }, {
165
196
  ttl: introspectNegativeCacheTtl
@@ -168,7 +199,7 @@ function createTokenVerifier(options) {
168
199
  return { active: false };
169
200
  }
170
201
  let payload = null;
171
- if (data.active && data.sub) {
202
+ if (data.sub) {
172
203
  payload = {
173
204
  sub: data.sub,
174
205
  aud: audience,
@@ -178,12 +209,12 @@ function createTokenVerifier(options) {
178
209
  exp: data.exp
179
210
  };
180
211
  }
181
- if (data.active) {
182
- introspectCache.set(token, { active: true, payload });
183
- } else if (introspectNegativeCacheTtl > 0) {
184
- introspectCache.set(token, { active: false, payload: null }, {
185
- ttl: introspectNegativeCacheTtl
186
- });
212
+ let ttl = introspectCacheTtl;
213
+ if (typeof data.exp === "number") {
214
+ ttl = Math.min(ttl, data.exp * 1e3 - Date.now());
215
+ }
216
+ if (ttl > 0) {
217
+ introspectCache.set(token, { active: true, payload }, { ttl });
187
218
  }
188
219
  return data;
189
220
  }
@@ -263,7 +294,7 @@ function createTokenVerifier(options) {
263
294
  const rs256Result = await verifyWithRS256(token);
264
295
  if (rs256Result) return rs256Result;
265
296
  const result = await introspect(token);
266
- if (!result?.active || !result.sub) return null;
297
+ if (result?.active !== true || !result.sub) return null;
267
298
  return {
268
299
  sub: result.sub,
269
300
  aud: audience,
@@ -324,10 +355,15 @@ function createTokenVerifier(options) {
324
355
  const iss = payload.iss || issuer;
325
356
  const jtiKey = `${iss}:${jti}`;
326
357
  if (logoutJtiStore) {
327
- if (await logoutJtiStore.has(jtiKey)) {
328
- return null;
358
+ if (typeof logoutJtiStore.addIfAbsent === "function") {
359
+ const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);
360
+ if (!firstUse) return null;
361
+ } else {
362
+ if (await logoutJtiStore.has(jtiKey)) {
363
+ return null;
364
+ }
365
+ await logoutJtiStore.add(jtiKey, 10 * 60);
329
366
  }
330
- await logoutJtiStore.add(jtiKey, 10 * 60);
331
367
  } else {
332
368
  if (processedLogoutJtis.has(jtiKey)) {
333
369
  return null;
@@ -357,7 +393,12 @@ function createTokenVerifier(options) {
357
393
  return null;
358
394
  }
359
395
  }
360
- const jwks = getJwksKeySet();
396
+ let jwks = null;
397
+ try {
398
+ jwks = getJwksKeySet();
399
+ } catch {
400
+ return null;
401
+ }
361
402
  if (jwks) {
362
403
  try {
363
404
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
@@ -436,3 +477,4 @@ function createLogoutTokenVerifier(options) {
436
477
  createTokenVerifier,
437
478
  ssoMiddleware
438
479
  });
480
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
package/dist/index.mjs CHANGED
@@ -34,8 +34,26 @@ function createTokenVerifier(options) {
34
34
  introspectRetries = 1,
35
35
  clockToleranceSeconds = 60,
36
36
  logoutJtiStore,
37
- strictAudience = false
37
+ strictAudience = true
38
38
  } = options;
39
+ const assertHttpsEndpoint = (value, name) => {
40
+ if (!value || process.env.NODE_ENV !== "production") return;
41
+ let url;
42
+ try {
43
+ url = new URL(value);
44
+ } catch {
45
+ throw new Error(`[sso-verify] ${name} \u4E0D\u662F\u5408\u6CD5 URL: ${value}`);
46
+ }
47
+ if (url.protocol === "https:") return;
48
+ const host = url.hostname.replace(/^\[|\]$/g, "");
49
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
50
+ if (url.protocol === "http:" && isLoopback) return;
51
+ throw new Error(
52
+ `[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`
53
+ );
54
+ };
55
+ assertHttpsEndpoint(introspectionEndpoint, "introspectionEndpoint");
56
+ assertHttpsEndpoint(jwksUri, "jwksUri");
39
57
  const introspectCache = createIntrospectCache(introspectCacheTtl);
40
58
  let _jwksKeySet = null;
41
59
  function getJwksKeySet() {
@@ -134,11 +152,24 @@ function createTokenVerifier(options) {
134
152
  if (response.status >= 500 && attempt < introspectRetries) continue;
135
153
  return null;
136
154
  }
137
- data = await response.json();
155
+ try {
156
+ data = await response.json();
157
+ } catch {
158
+ if (attempt < introspectRetries) continue;
159
+ return null;
160
+ }
138
161
  break;
139
162
  }
140
163
  if (!data) return null;
141
- if (data.active && !matchesAudience(data)) {
164
+ if (data.active !== true) {
165
+ if (introspectNegativeCacheTtl > 0) {
166
+ introspectCache.set(token, { active: false, payload: null }, {
167
+ ttl: introspectNegativeCacheTtl
168
+ });
169
+ }
170
+ return { active: false };
171
+ }
172
+ if (!matchesAudience(data)) {
142
173
  if (introspectNegativeCacheTtl > 0) {
143
174
  introspectCache.set(token, { active: false, payload: null }, {
144
175
  ttl: introspectNegativeCacheTtl
@@ -147,7 +178,7 @@ function createTokenVerifier(options) {
147
178
  return { active: false };
148
179
  }
149
180
  let payload = null;
150
- if (data.active && data.sub) {
181
+ if (data.sub) {
151
182
  payload = {
152
183
  sub: data.sub,
153
184
  aud: audience,
@@ -157,12 +188,12 @@ function createTokenVerifier(options) {
157
188
  exp: data.exp
158
189
  };
159
190
  }
160
- if (data.active) {
161
- introspectCache.set(token, { active: true, payload });
162
- } else if (introspectNegativeCacheTtl > 0) {
163
- introspectCache.set(token, { active: false, payload: null }, {
164
- ttl: introspectNegativeCacheTtl
165
- });
191
+ let ttl = introspectCacheTtl;
192
+ if (typeof data.exp === "number") {
193
+ ttl = Math.min(ttl, data.exp * 1e3 - Date.now());
194
+ }
195
+ if (ttl > 0) {
196
+ introspectCache.set(token, { active: true, payload }, { ttl });
166
197
  }
167
198
  return data;
168
199
  }
@@ -242,7 +273,7 @@ function createTokenVerifier(options) {
242
273
  const rs256Result = await verifyWithRS256(token);
243
274
  if (rs256Result) return rs256Result;
244
275
  const result = await introspect(token);
245
- if (!result?.active || !result.sub) return null;
276
+ if (result?.active !== true || !result.sub) return null;
246
277
  return {
247
278
  sub: result.sub,
248
279
  aud: audience,
@@ -303,10 +334,15 @@ function createTokenVerifier(options) {
303
334
  const iss = payload.iss || issuer;
304
335
  const jtiKey = `${iss}:${jti}`;
305
336
  if (logoutJtiStore) {
306
- if (await logoutJtiStore.has(jtiKey)) {
307
- return null;
337
+ if (typeof logoutJtiStore.addIfAbsent === "function") {
338
+ const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);
339
+ if (!firstUse) return null;
340
+ } else {
341
+ if (await logoutJtiStore.has(jtiKey)) {
342
+ return null;
343
+ }
344
+ await logoutJtiStore.add(jtiKey, 10 * 60);
308
345
  }
309
- await logoutJtiStore.add(jtiKey, 10 * 60);
310
346
  } else {
311
347
  if (processedLogoutJtis.has(jtiKey)) {
312
348
  return null;
@@ -336,7 +372,12 @@ function createTokenVerifier(options) {
336
372
  return null;
337
373
  }
338
374
  }
339
- const jwks = getJwksKeySet();
375
+ let jwks = null;
376
+ try {
377
+ jwks = getJwksKeySet();
378
+ } catch {
379
+ return null;
380
+ }
340
381
  if (jwks) {
341
382
  try {
342
383
  const { payload } = await jwtVerify(token, jwks, {
@@ -414,3 +455,4 @@ export {
414
455
  createTokenVerifier,
415
456
  ssoMiddleware
416
457
  };
458
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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":";AAgCA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP,SAAS,gBAAgB;AA2LzB,SAAS,sBAAsB,OAAe;AAC5C,SAAO,IAAI,SAAuC;AAAA,IAChD,KAAK;AAAA,IACL,KAAK;AAAA,EACP,CAAC;AACH;AAUA,IAAM,sBAAsB,IAAI,SAAyB;AAAA,EACvD,KAAK;AAAA,EACL,KAAK,KAAK,KAAK;AACjB,CAAC;AAMM,SAAS,oBAAoB,SAA6B;AAC/D,QAAM;AAAA,IACJ;AAAA,IACA,SAAS;AAAA,IACT;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,qBAAqB,KAAK;AAAA,IAC1B,sBAAsB,KAAK;AAAA,IAC3B,6BAA6B,IAAI;AAAA,IACjC,oBAAoB;AAAA,IACpB,wBAAwB;AAAA,IACxB;AAAA,IACA,iBAAiB;AAAA,EACnB,IAAI;AAIJ,QAAM,sBAAsB,CAAC,OAA2B,SAAuB;AAC7E,QAAI,CAAC,SAAS,QAAQ,IAAI,aAAa,aAAc;AACrD,QAAI;AACJ,QAAI;AACF,YAAM,IAAI,IAAI,KAAK;AAAA,IACrB,QAAQ;AACN,YAAM,IAAI,MAAM,gBAAgB,IAAI,kCAAc,KAAK,EAAE;AAAA,IAC3D;AACA,QAAI,IAAI,aAAa,SAAU;AAC/B,UAAM,OAAO,IAAI,SAAS,QAAQ,YAAY,EAAE;AAChD,UAAM,aAAa,SAAS,eAAe,SAAS,eAAe,SAAS;AAC5E,QAAI,IAAI,aAAa,WAAW,WAAY;AAC5C,UAAM,IAAI;AAAA,MACR,yCAAqB,IAAI,kDAAoB,IAAI,QAAQ,KAAK,IAAI,IAAI;AAAA,IAExE;AAAA,EACF;AACA,sBAAoB,uBAAuB,uBAAuB;AAClE,sBAAoB,SAAS,SAAS;AAEtC,QAAM,kBAAkB,sBAAsB,kBAAkB;AAGhE,MAAI,cAA4D;AAChE,WAAS,gBAAgB;AACvB,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,CAAC,aAAa;AAChB,oBAAc,mBAAmB,IAAI,IAAI,OAAO,GAAG;AAAA,QACjD,aAAa,KAAK,KAAK;AAAA;AAAA,MACzB,CAAC;AAAA,IACH;AACA,WAAO;AAAA,EACT;AAGA,MAAI,kBAAkC;AACtC,MAAI,uBAAuB;AAC3B,iBAAe,oBAA6C;AAC1D,QAAI,CAAC,sBAAsB;AACzB,6BAAuB;AACvB,UAAI,CAAC,sBAAsB;AACzB,0BAAkB;AAAA,MACpB,OAAO;AACL,YAAI;AACF,4BAAkB,MAAM,WAAW,sBAAsB,OAAO;AAAA,QAClE,QAAQ;AACN,4BAAkB;AAAA,QACpB;AAAA,MACF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAGA,MAAI,wBAAwC;AAC5C,MAAI,6BAA6B;AACjC,iBAAe,0BAAmD;AAChE,QAAI,CAAC,4BAA4B;AAC/B,mCAA6B;AAC7B,UAAI,CAAC,sBAAsB;AACzB,gCAAwB;AAAA,MAC1B,OAAO;AACL,YAAI;AACF,kCAAwB,MAAM,WAAW,sBAAsB,OAAO;AAAA,QACxE,QAAQ;AACN,kCAAwB;AAAA,QAC1B;AAAA,MACF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AASA,WAAS,gBAAgB,MAAmC;AAC1D,UAAM,MAAM,KAAK;AACjB,QAAI,OAAO,QAAQ,SAAU,QAAO,QAAQ;AAC5C,QAAI,MAAM,QAAQ,GAAG,EAAG,QAAO,IAAI,SAAS,QAAQ;AACpD,QAAI,OAAO,KAAK,cAAc,SAAU,QAAO,KAAK,cAAc;AAClE,WAAO,CAAC;AAAA,EACV;AAGA,QAAM,sBAAsB,oBAAI,IAAgD;AAQhF,iBAAe,WAAW,OAAmD;AAC3E,QAAI,CAAC,yBAAyB,CAAC,UAAU;AACvC,aAAO;AAAA,IACT;AAEA,UAAM,SAAS,gBAAgB,IAAI,KAAK;AACxC,QAAI,QAAQ;AACV,aAAO,EAAE,QAAQ,OAAO,QAAQ,GAAI,OAAO,WAAW,CAAC,EAAG;AAAA,IAC5D;AAEA,UAAM,WAAW,oBAAoB,IAAI,KAAK;AAC9C,QAAI,UAAU;AACZ,aAAO;AAAA,IACT;AAEA,UAAM,UAAU,aAAa,KAAK,EAAE,QAAQ,MAAM;AAChD,0BAAoB,OAAO,KAAK;AAAA,IAClC,CAAC;AACD,wBAAoB,IAAI,OAAO,OAAO;AACtC,WAAO;AAAA,EACT;AAEA,iBAAe,aAAa,OAAmD;AAC7E,UAAM,SAAS,IAAI,gBAAgB,EAAE,OAAO,WAAW,SAAU,CAAC;AAClE,QAAI,cAAc;AAChB,aAAO,IAAI,iBAAiB,YAAY;AAAA,IAC1C;AAGA,QAAI,OAAkC;AACtC,aAAS,UAAU,KAAK,WAAW;AACjC,UAAI,UAAU,GAAG;AACf,cAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,MAAM,OAAO,CAAC;AAAA,MACnE;AACA,UAAI;AACJ,UAAI;AACF,mBAAW,MAAM,MAAM,uBAAwB;AAAA,UAC7C,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,UAC/D,MAAM;AAAA,UACN,QAAQ,YAAY,QAAQ,mBAAmB;AAAA,QACjD,CAAC;AAAA,MACH,QAAQ;AACN,YAAI,UAAU,kBAAmB;AACjC,eAAO;AAAA,MACT;AACA,UAAI,CAAC,SAAS,IAAI;AAChB,YAAI,SAAS,UAAU,OAAO,UAAU,kBAAmB;AAC3D,eAAO;AAAA,MACT;AAGA,UAAI;AACF,eAAQ,MAAM,SAAS,KAAK;AAAA,MAC9B,QAAQ;AACN,YAAI,UAAU,kBAAmB;AACjC,eAAO;AAAA,MACT;AACA;AAAA,IACF;AACA,QAAI,CAAC,KAAM,QAAO;AAGlB,QAAI,KAAK,WAAW,MAAM;AACxB,UAAI,6BAA6B,GAAG;AAClC,wBAAgB,IAAI,OAAO,EAAE,QAAQ,OAAO,SAAS,KAAK,GAAG;AAAA,UAC3D,KAAK;AAAA,QACP,CAAC;AAAA,MACH;AACA,aAAO,EAAE,QAAQ,MAAM;AAAA,IACzB;AAGA,QAAI,CAAC,gBAAgB,IAAI,GAAG;AAC1B,UAAI,6BAA6B,GAAG;AAClC,wBAAgB,IAAI,OAAO,EAAE,QAAQ,OAAO,SAAS,KAAK,GAAG;AAAA,UAC3D,KAAK;AAAA,QACP,CAAC;AAAA,MACH;AACA,aAAO,EAAE,QAAQ,MAAM;AAAA,IACzB;AAEA,QAAI,UAAuC;AAC3C,QAAI,KAAK,KAAK;AACZ,gBAAU;AAAA,QACR,KAAK,KAAK;AAAA,QACV,KAAK;AAAA,QACL,KAAK;AAAA,QACL,WAAW,KAAK;AAAA,QAChB,OAAO,KAAK;AAAA,QACZ,KAAK,KAAK;AAAA,MACZ;AAAA,IACF;AAIA,QAAI,MAAM;AACV,QAAI,OAAO,KAAK,QAAQ,UAAU;AAChC,YAAM,KAAK,IAAI,KAAK,KAAK,MAAM,MAAO,KAAK,IAAI,CAAC;AAAA,IAClD;AACA,QAAI,MAAM,GAAG;AACX,sBAAgB,IAAI,OAAO,EAAE,QAAQ,MAAM,QAAQ,GAAG,EAAE,IAAI,CAAC;AAAA,IAC/D;AACA,WAAO;AAAA,EACT;AASA,WAAS,sBAAsB,SAAkD;AAC/E,UAAM,IAAI;AACV,QAAI,CAAC,EAAE,OAAO,OAAO,EAAE,OAAO,YAAY,EAAE,IAAI;AAC9C,QAAE,MAAM,EAAE;AAAA,IACZ;AACA,WAAO,EAAE,MAAM,IAAI;AAAA,EACrB;AAKA,iBAAe,cAAc,OAAqD;AAChF,QAAI,CAAC,kBAAmB,QAAO;AAE/B,QAAI;AACF,YAAM,SAAS,IAAI,YAAY,EAAE,OAAO,iBAAiB;AACzD,YAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,QAAQ;AAAA,QACjD;AAAA,QACA;AAAA,QACA,YAAY,CAAC,OAAO;AAAA,QACpB,gBAAgB;AAAA,MAClB,CAAC;AAED,UAAK,QAA8B,SAAS,gBAAgB;AAC1D,eAAO;AAAA,MACT;AAEA,aAAO,sBAAsB,OAAO;AAAA,IACtC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAKA,iBAAe,gBAAgB,OAAqD;AAElF,UAAM,eAAe,QAAQ,oBAAoB;AACjD,UAAM,UAAU,QAAQ,OAAO;AAC/B,QAAI,CAAC,gBAAgB,CAAC,QAAS,QAAO;AAEtC,QAAI;AAEF,YAAM,YAAY,MAAM,kBAAkB;AAC1C,UAAI,WAAW;AACb,cAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,WAAW;AAAA,UACpD;AAAA,UACA;AAAA,UACA,YAAY,CAAC,OAAO;AAAA,UACpB,gBAAgB;AAAA,QAClB,CAAC;AACD,YAAK,QAA8B,SAAS,eAAgB,QAAO;AACnE,eAAO,sBAAsB,OAAO;AAAA,MACtC;AAGA,YAAM,OAAO,cAAc;AAC3B,UAAI,MAAM;AACR,cAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,MAAM;AAAA,UAC/C;AAAA,UACA;AAAA,UACA,YAAY,CAAC,OAAO;AAAA,UACpB,gBAAgB;AAAA,QAClB,CAAC;AACD,YAAK,QAA8B,SAAS,eAAgB,QAAO;AACnE,eAAO,sBAAsB,OAAO;AAAA,MACtC;AAEA,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWL,MAAM,OAAO,OAAqD;AAEhE,UAAI,mBAAmB;AACrB,cAAM,QAAQ,MAAM,cAAc,KAAK;AACvC,YAAI,MAAO,QAAO;AAAA,MACpB;AAGA,YAAM,cAAc,MAAM,gBAAgB,KAAK;AAC/C,UAAI,YAAa,QAAO;AAGxB,YAAM,SAAS,MAAM,WAAW,KAAK;AAErC,UAAI,QAAQ,WAAW,QAAQ,CAAC,OAAO,IAAK,QAAO;AAEnD,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ,KAAK;AAAA,QACL,KAAK;AAAA,QACL,WAAW,OAAO;AAAA,QAClB,OAAO,OAAO;AAAA,QACd,KAAK,OAAO;AAAA,MACd;AAAA,IACF;AAAA;AAAA;AAAA;AAAA,IAKA;AAAA;AAAA;AAAA;AAAA,IAKA,gBAAgB,OAAqB;AACnC,sBAAgB,OAAO,KAAK;AAAA,IAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBA,MAAM,kBAAkB,OAAmD;AAEzE,YAAM,wBAAwB,OAC5B,YACuC;AACvC,YAAK,QAA8B,SAAS,gBAAgB;AAC1D,iBAAO;AAAA,QACT;AAEA,YAAI,OAAQ,QAA8B,QAAQ,UAAU;AAC1D,iBAAO;AAAA,QACT;AACA,cAAM,SAAU,QAAiD;AACjE,cAAM,cACJ,SAAS,oDAAoD;AAE/D,YACE,CAAC,eACD,OAAO,gBAAgB,YACvB,MAAM,QAAQ,WAAW,GACzB;AACA,iBAAO;AAAA,QACT;AACA,cAAM,MAAO,QAA6B;AAC1C,YAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,iBAAO;AAAA,QACT;AAGA,cAAM,MAAO,QAA8B;AAC3C,cAAM,MAAO,QAA8B;AAC3C,cAAM,SAAS,OAAO,QAAQ,YAAY,IAAI,SAAS;AACvD,cAAM,SAAS,OAAO,QAAQ,YAAY,IAAI,SAAS;AACvD,YAAI,CAAC,UAAU,CAAC,QAAQ;AACtB,iBAAO;AAAA,QACT;AAEA,cAAM,MAAO,QAA6B,OAAO;AACjD,cAAM,SAAS,GAAG,GAAG,IAAI,GAAG;AAC5B,YAAI,gBAAgB;AAElB,cAAI,OAAO,eAAe,gBAAgB,YAAY;AACpD,kBAAM,WAAW,MAAM,eAAe,YAAY,QAAQ,KAAK,EAAE;AACjE,gBAAI,CAAC,SAAU,QAAO;AAAA,UACxB,OAAO;AAGL,gBAAI,MAAM,eAAe,IAAI,MAAM,GAAG;AACpC,qBAAO;AAAA,YACT;AACA,kBAAM,eAAe,IAAI,QAAQ,KAAK,EAAE;AAAA,UAC1C;AAAA,QACF,OAAO;AACL,cAAI,oBAAoB,IAAI,MAAM,GAAG;AACnC,mBAAO;AAAA,UACT;AACA,8BAAoB,IAAI,QAAQ,KAAK,IAAI,CAAC;AAAA,QAC5C;AACA,eAAO;AAAA,MACT;AAGA,UAAI;AACJ,UAAI;AACF,cAAM,sBAAsB,KAAK,EAAE;AAAA,MACrC,QAAQ;AACN,eAAO;AAAA,MACT;AAIA,UAAI,QAAQ,SAAS;AACnB,cAAM,YAAY,MAAM,wBAAwB;AAChD,YAAI,WAAW;AACb,cAAI;AACF,kBAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,WAAW;AAAA,cACpD;AAAA,cACA;AAAA,cACA,YAAY,CAAC,OAAO;AAAA,cACpB,gBAAgB;AAAA,YAClB,CAAC;AACD,mBAAO,MAAM,sBAAsB,OAA6C;AAAA,UAClF,QAAQ;AACN,mBAAO;AAAA,UACT;AAAA,QACF;AAIA,YAAI,OAAyC;AAC7C,YAAI;AACF,iBAAO,cAAc;AAAA,QACvB,QAAQ;AACN,iBAAO;AAAA,QACT;AACA,YAAI,MAAM;AACR,cAAI;AACF,kBAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,MAAM;AAAA,cAC/C;AAAA,cACA;AAAA,cACA,YAAY,CAAC,OAAO;AAAA,cACpB,gBAAgB;AAAA,YAClB,CAAC;AACD,mBAAO,MAAM,sBAAsB,OAA6C;AAAA,UAClF,QAAQ;AACN,mBAAO;AAAA,UACT;AAAA,QACF;AAEA,eAAO;AAAA,MACT;AAGA,UAAI,QAAQ,SAAS;AACnB,cAAM,SAAS,qBAAqB;AACpC,YAAI,CAAC,OAAQ,QAAO;AAEpB,YAAI;AACF,gBAAM,MAAM,IAAI,YAAY,EAAE,OAAO,MAAM;AAC3C,gBAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,KAAK;AAAA,YAC9C;AAAA,YACA;AAAA,YACA,YAAY,CAAC,OAAO;AAAA,YACpB,gBAAgB;AAAA,UAClB,CAAC;AACD,iBAAO,MAAM,sBAAsB,OAA6C;AAAA,QAClF,QAAQ;AACN,iBAAO;AAAA,QACT;AAAA,MACF;AAEA,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAwCO,SAAS,cAAc,SAA6B;AACzD,QAAM,WAAW,oBAAoB,OAAO;AAE5C,SAAO,OAAO,KAA2B,KAA4B,SAAkC;AACrG,UAAM,aAAa,IAAI,SAAS,iBAAiB,IAAI,SAAS,iBAAiB;AAC/E,UAAM,QAAQ,WAAW,WAAW,SAAS,IAAI,WAAW,MAAM,CAAC,IAAI;AAEvE,QAAI,CAAC,OAAO;AACV,UAAI,IAAI,QAAQ;AACd,YAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,gBAAgB,SAAS,kCAAmB,CAAC;AAC3E;AAAA,MACF;AAEA,WAAK,IAAI,MAAM,+CAAgC,CAAC;AAChD;AAAA,IACF;AAEA,UAAM,UAAU,MAAM,SAAS,OAAO,KAAK;AAC3C,QAAI,CAAC,SAAS;AACZ,UAAI,IAAI,QAAQ;AACd,YAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,gBAAgB,SAAS,6CAAe,CAAC;AACvE;AAAA,MACF;AACA,WAAK,IAAI,MAAM,0DAA4B,CAAC;AAC5C;AAAA,IACF;AAGA,QAAI,OAAO;AACX,SAAK;AAAA,EACP;AACF;AA+BO,SAAS,0BAA0B,SAA6B;AACrE,QAAM,WAAW,oBAAoB,OAAO;AAE5C,SAAO;AAAA;AAAA;AAAA;AAAA,IAIL,MAAM,OAAO,OAAmD;AAC9D,aAAO,SAAS,kBAAkB,KAAK;AAAA,IACzC;AAAA,EACF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -17,8 +17,8 @@
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
20
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
21
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
20
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
21
+ "dev": "tsup src/index.ts --format esm,cjs --dts --sourcemap --watch",
22
22
  "test": "vitest run",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "prepublishOnly": "npm run build"
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "tsup": "^8.0.0",
50
- "typescript": "^5.0.0"
50
+ "typescript": "^5.0.0",
51
+ "vitest": "^3.2.7"
51
52
  }
52
53
  }