@nihplod/sso-verify 1.0.3 → 1.1.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
@@ -89,3 +89,23 @@ After successful verification, the token payload is attached to `req.user`.
89
89
  - The main site currently uses HS256 symmetric signing by default. For external sub-projects, we recommend verifying access tokens via **Introspection**.
90
90
  - If your sub-project is an internal service and already shares the RS256 public key with the main site, you can configure `accessTokenPublicKey` or `jwksUri` for local JWT verification.
91
91
  - **Never distribute `JWT_ACCESS_SECRET` (the HS256 symmetric key) to external sub-projects.**
92
+
93
+ ## Advanced Options
94
+
95
+ | Option | Default | Description |
96
+ | --- | --- | --- |
97
+ | `introspectCacheTtl` | `30000` | Cache TTL (ms) for `active: true` introspection results |
98
+ | `introspectNegativeCacheTtl` | `5000` | Cache TTL (ms) for `active: false` results; set `0` to disable caching of revoked/invalid tokens |
99
+ | `introspectTimeoutMs` | `10000` | Introspection request timeout (ms); a timeout is treated as verification failure |
100
+ | `accessTokenPublicKey` | — | RS256 public key (PEM) for local access token verification |
101
+ | `jwksUri` | — | JWKS endpoint URL; public keys are matched by `kid` |
102
+ | `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
103
+ | `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
104
+
105
+ ### Revocation latency trade-off
106
+
107
+ 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.
108
+
109
+ ### Logout token verification
110
+
111
+ Back-channel `logout_token`s are signed with a dedicated key pair (`kid: logout-token-rs256-v1`), which is **different** from the access token key — do not use `accessTokenPublicKey` for them. Configure `logoutTokenPublicKey` (PEM) or `jwksUri` (keys matched by `kid`). Verification is dispatched by the JWT header `alg`: an RS256 logout token without any matching public key fails closed (returns `null`) instead of silently falling back to HS256; HS256 is only used when `logoutTokenSecret` (or the legacy `accessTokenSecret` fallback) is explicitly configured. Verified `jti`s are replay-guarded per issuer for 10 minutes.
package/dist/index.d.mts CHANGED
@@ -53,6 +53,17 @@ interface SsoVerifierOptions {
53
53
  accessTokenSecret?: string;
54
54
  /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
55
55
  introspectCacheTtl?: number;
56
+ /**
57
+ * Introspection 请求超时时间(毫秒),默认 10 秒。
58
+ * 超时按验证失败处理(返回 null)。
59
+ */
60
+ introspectTimeoutMs?: number;
61
+ /**
62
+ * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
63
+ * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
64
+ * 设为 0 表示不缓存 active:false 结果。
65
+ */
66
+ introspectNegativeCacheTtl?: number;
56
67
  /**
57
68
  * Access Token RS256 公钥(PEM 格式,可选)。
58
69
  * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
@@ -71,6 +82,15 @@ interface SsoVerifierOptions {
71
82
  * 若未提供,将回退使用 accessTokenSecret 进行验证。
72
83
  */
73
84
  logoutTokenSecret?: string;
85
+ /**
86
+ * Logout Token RS256 公钥(PEM 格式,可选)。
87
+ * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
88
+ * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
89
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
90
+ * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
91
+ * 不会静默回退到 HS256。
92
+ */
93
+ logoutTokenPublicKey?: string;
74
94
  }
75
95
  interface VerifiedTokenPayload extends JWTPayload {
76
96
  sub: string;
@@ -153,6 +173,20 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
153
173
  */
154
174
  verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
155
175
  };
176
+ /** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
177
+ interface SsoMiddlewareRequest {
178
+ headers?: Record<string, string | undefined>;
179
+ /** 验证通过后由中间件挂载的用户信息 */
180
+ user?: VerifiedTokenPayload;
181
+ [key: string]: unknown;
182
+ }
183
+ /** 框架无关的最小中间件响应类型 */
184
+ interface SsoMiddlewareResponse {
185
+ status?: (code: number) => {
186
+ json: (body: unknown) => unknown;
187
+ };
188
+ [key: string]: unknown;
189
+ }
156
190
  /**
157
191
  * Express/Next.js 兼容中间件
158
192
  *
@@ -177,7 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
177
211
  * }
178
212
  * ```
179
213
  */
180
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
214
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
181
215
  /**
182
216
  * 创建 Logout Token 专用验证器
183
217
  *
@@ -214,4 +248,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
214
248
  verify(token: string): Promise<LogoutTokenPayload | null>;
215
249
  };
216
250
 
217
- export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
251
+ export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
package/dist/index.d.ts CHANGED
@@ -53,6 +53,17 @@ interface SsoVerifierOptions {
53
53
  accessTokenSecret?: string;
54
54
  /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
55
55
  introspectCacheTtl?: number;
56
+ /**
57
+ * Introspection 请求超时时间(毫秒),默认 10 秒。
58
+ * 超时按验证失败处理(返回 null)。
59
+ */
60
+ introspectTimeoutMs?: number;
61
+ /**
62
+ * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
63
+ * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
64
+ * 设为 0 表示不缓存 active:false 结果。
65
+ */
66
+ introspectNegativeCacheTtl?: number;
56
67
  /**
57
68
  * Access Token RS256 公钥(PEM 格式,可选)。
58
69
  * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
@@ -71,6 +82,15 @@ interface SsoVerifierOptions {
71
82
  * 若未提供,将回退使用 accessTokenSecret 进行验证。
72
83
  */
73
84
  logoutTokenSecret?: string;
85
+ /**
86
+ * Logout Token RS256 公钥(PEM 格式,可选)。
87
+ * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
88
+ * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
89
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
90
+ * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
91
+ * 不会静默回退到 HS256。
92
+ */
93
+ logoutTokenPublicKey?: string;
74
94
  }
75
95
  interface VerifiedTokenPayload extends JWTPayload {
76
96
  sub: string;
@@ -153,6 +173,20 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
153
173
  */
154
174
  verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
155
175
  };
176
+ /** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
177
+ interface SsoMiddlewareRequest {
178
+ headers?: Record<string, string | undefined>;
179
+ /** 验证通过后由中间件挂载的用户信息 */
180
+ user?: VerifiedTokenPayload;
181
+ [key: string]: unknown;
182
+ }
183
+ /** 框架无关的最小中间件响应类型 */
184
+ interface SsoMiddlewareResponse {
185
+ status?: (code: number) => {
186
+ json: (body: unknown) => unknown;
187
+ };
188
+ [key: string]: unknown;
189
+ }
156
190
  /**
157
191
  * Express/Next.js 兼容中间件
158
192
  *
@@ -177,7 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
177
211
  * }
178
212
  * ```
179
213
  */
180
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
214
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
181
215
  /**
182
216
  * 创建 Logout Token 专用验证器
183
217
  *
@@ -214,4 +248,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
214
248
  verify(token: string): Promise<LogoutTokenPayload | null>;
215
249
  };
216
250
 
217
- export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
251
+ export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
package/dist/index.js CHANGED
@@ -48,7 +48,10 @@ function createTokenVerifier(options) {
48
48
  accessTokenPublicKey,
49
49
  jwksUri,
50
50
  logoutTokenSecret,
51
- introspectCacheTtl = 30 * 1e3
51
+ logoutTokenPublicKey,
52
+ introspectCacheTtl = 30 * 1e3,
53
+ introspectTimeoutMs = 10 * 1e3,
54
+ introspectNegativeCacheTtl = 5 * 1e3
52
55
  } = options;
53
56
  const introspectCache = createIntrospectCache(introspectCacheTtl);
54
57
  let _jwksKeySet = null;
@@ -79,6 +82,23 @@ function createTokenVerifier(options) {
79
82
  }
80
83
  return _rs256PublicKey;
81
84
  }
85
+ let _logoutRs256PublicKey = null;
86
+ let _logoutRs256KeyInitialized = false;
87
+ async function getLogoutRS256PublicKey() {
88
+ if (!_logoutRs256KeyInitialized) {
89
+ _logoutRs256KeyInitialized = true;
90
+ if (!logoutTokenPublicKey) {
91
+ _logoutRs256PublicKey = null;
92
+ } else {
93
+ try {
94
+ _logoutRs256PublicKey = await (0, import_jose.importSPKI)(logoutTokenPublicKey, "RS256");
95
+ } catch {
96
+ _logoutRs256PublicKey = null;
97
+ }
98
+ }
99
+ }
100
+ return _logoutRs256PublicKey;
101
+ }
82
102
  async function introspect(token) {
83
103
  if (!introspectionEndpoint || !clientId) {
84
104
  return null;
@@ -95,7 +115,8 @@ function createTokenVerifier(options) {
95
115
  const response = await fetch(introspectionEndpoint, {
96
116
  method: "POST",
97
117
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
98
- body: params
118
+ body: params,
119
+ signal: AbortSignal.timeout(introspectTimeoutMs)
99
120
  });
100
121
  if (!response.ok) return null;
101
122
  const data = await response.json();
@@ -110,7 +131,13 @@ function createTokenVerifier(options) {
110
131
  exp: data.exp
111
132
  };
112
133
  }
113
- introspectCache.set(token, { active: data.active, payload });
134
+ if (data.active) {
135
+ introspectCache.set(token, { active: true, payload });
136
+ } else if (introspectNegativeCacheTtl > 0) {
137
+ introspectCache.set(token, { active: false, payload: null }, {
138
+ ttl: introspectNegativeCacheTtl
139
+ });
140
+ }
114
141
  return data;
115
142
  } catch {
116
143
  return null;
@@ -122,7 +149,8 @@ function createTokenVerifier(options) {
122
149
  const secret = new TextEncoder().encode(accessTokenSecret);
123
150
  const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
124
151
  issuer,
125
- audience
152
+ audience,
153
+ algorithms: ["HS256"]
126
154
  });
127
155
  if (payload.type !== "access_token") {
128
156
  return null;
@@ -181,9 +209,9 @@ function createTokenVerifier(options) {
181
209
  const rs256Result = await verifyWithRS256(token);
182
210
  if (rs256Result) return rs256Result;
183
211
  const result = await introspect(token);
184
- if (!result?.active) return null;
212
+ if (!result?.active || !result.sub) return null;
185
213
  return {
186
- sub: result.sub || "",
214
+ sub: result.sub,
187
215
  aud: audience,
188
216
  iss: issuer,
189
217
  client_id: result.client_id,
@@ -214,15 +242,7 @@ function createTokenVerifier(options) {
214
242
  * @returns LogoutTokenPayload 或 null(验证失败)
215
243
  */
216
244
  async verifyLogoutToken(token) {
217
- const secret = logoutTokenSecret || accessTokenSecret;
218
- if (!secret) return null;
219
- try {
220
- const key = new TextEncoder().encode(secret);
221
- const { payload } = await (0, import_jose.jwtVerify)(token, key, {
222
- issuer,
223
- audience,
224
- algorithms: ["HS256"]
225
- });
245
+ const validateLogoutPayload = (payload) => {
226
246
  if (payload.type !== "logout_token") {
227
247
  return null;
228
248
  }
@@ -234,14 +254,65 @@ function createTokenVerifier(options) {
234
254
  if (!jti || typeof jti !== "string") {
235
255
  return null;
236
256
  }
237
- if (processedLogoutJtis.has(jti)) {
257
+ const iss = payload.iss || issuer;
258
+ const jtiKey = `${iss}:${jti}`;
259
+ if (processedLogoutJtis.has(jtiKey)) {
238
260
  return null;
239
261
  }
240
- processedLogoutJtis.set(jti, Date.now());
262
+ processedLogoutJtis.set(jtiKey, Date.now());
241
263
  return payload;
264
+ };
265
+ let alg;
266
+ try {
267
+ alg = (0, import_jose.decodeProtectedHeader)(token).alg;
242
268
  } catch {
243
269
  return null;
244
270
  }
271
+ if (alg === "RS256") {
272
+ const directKey = await getLogoutRS256PublicKey();
273
+ if (directKey) {
274
+ try {
275
+ const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
276
+ issuer,
277
+ audience,
278
+ algorithms: ["RS256"]
279
+ });
280
+ return validateLogoutPayload(payload);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+ const jwks = getJwksKeySet();
286
+ if (jwks) {
287
+ try {
288
+ const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
289
+ issuer,
290
+ audience,
291
+ algorithms: ["RS256"]
292
+ });
293
+ return validateLogoutPayload(payload);
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+ if (alg === "HS256") {
301
+ const secret = logoutTokenSecret || accessTokenSecret;
302
+ if (!secret) return null;
303
+ try {
304
+ const key = new TextEncoder().encode(secret);
305
+ const { payload } = await (0, import_jose.jwtVerify)(token, key, {
306
+ issuer,
307
+ audience,
308
+ algorithms: ["HS256"]
309
+ });
310
+ return validateLogoutPayload(payload);
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+ return null;
245
316
  }
246
317
  };
247
318
  }
package/dist/index.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  // src/index.ts
2
- import { jwtVerify, importSPKI, createRemoteJWKSet } from "jose";
2
+ import {
3
+ jwtVerify,
4
+ importSPKI,
5
+ createRemoteJWKSet,
6
+ decodeProtectedHeader
7
+ } from "jose";
3
8
  import { LRUCache } from "lru-cache";
4
9
  function createIntrospectCache(ttlMs) {
5
10
  return new LRUCache({
@@ -22,7 +27,10 @@ function createTokenVerifier(options) {
22
27
  accessTokenPublicKey,
23
28
  jwksUri,
24
29
  logoutTokenSecret,
25
- introspectCacheTtl = 30 * 1e3
30
+ logoutTokenPublicKey,
31
+ introspectCacheTtl = 30 * 1e3,
32
+ introspectTimeoutMs = 10 * 1e3,
33
+ introspectNegativeCacheTtl = 5 * 1e3
26
34
  } = options;
27
35
  const introspectCache = createIntrospectCache(introspectCacheTtl);
28
36
  let _jwksKeySet = null;
@@ -53,6 +61,23 @@ function createTokenVerifier(options) {
53
61
  }
54
62
  return _rs256PublicKey;
55
63
  }
64
+ let _logoutRs256PublicKey = null;
65
+ let _logoutRs256KeyInitialized = false;
66
+ async function getLogoutRS256PublicKey() {
67
+ if (!_logoutRs256KeyInitialized) {
68
+ _logoutRs256KeyInitialized = true;
69
+ if (!logoutTokenPublicKey) {
70
+ _logoutRs256PublicKey = null;
71
+ } else {
72
+ try {
73
+ _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, "RS256");
74
+ } catch {
75
+ _logoutRs256PublicKey = null;
76
+ }
77
+ }
78
+ }
79
+ return _logoutRs256PublicKey;
80
+ }
56
81
  async function introspect(token) {
57
82
  if (!introspectionEndpoint || !clientId) {
58
83
  return null;
@@ -69,7 +94,8 @@ function createTokenVerifier(options) {
69
94
  const response = await fetch(introspectionEndpoint, {
70
95
  method: "POST",
71
96
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
72
- body: params
97
+ body: params,
98
+ signal: AbortSignal.timeout(introspectTimeoutMs)
73
99
  });
74
100
  if (!response.ok) return null;
75
101
  const data = await response.json();
@@ -84,7 +110,13 @@ function createTokenVerifier(options) {
84
110
  exp: data.exp
85
111
  };
86
112
  }
87
- introspectCache.set(token, { active: data.active, payload });
113
+ if (data.active) {
114
+ introspectCache.set(token, { active: true, payload });
115
+ } else if (introspectNegativeCacheTtl > 0) {
116
+ introspectCache.set(token, { active: false, payload: null }, {
117
+ ttl: introspectNegativeCacheTtl
118
+ });
119
+ }
88
120
  return data;
89
121
  } catch {
90
122
  return null;
@@ -96,7 +128,8 @@ function createTokenVerifier(options) {
96
128
  const secret = new TextEncoder().encode(accessTokenSecret);
97
129
  const { payload } = await jwtVerify(token, secret, {
98
130
  issuer,
99
- audience
131
+ audience,
132
+ algorithms: ["HS256"]
100
133
  });
101
134
  if (payload.type !== "access_token") {
102
135
  return null;
@@ -155,9 +188,9 @@ function createTokenVerifier(options) {
155
188
  const rs256Result = await verifyWithRS256(token);
156
189
  if (rs256Result) return rs256Result;
157
190
  const result = await introspect(token);
158
- if (!result?.active) return null;
191
+ if (!result?.active || !result.sub) return null;
159
192
  return {
160
- sub: result.sub || "",
193
+ sub: result.sub,
161
194
  aud: audience,
162
195
  iss: issuer,
163
196
  client_id: result.client_id,
@@ -188,15 +221,7 @@ function createTokenVerifier(options) {
188
221
  * @returns LogoutTokenPayload 或 null(验证失败)
189
222
  */
190
223
  async verifyLogoutToken(token) {
191
- const secret = logoutTokenSecret || accessTokenSecret;
192
- if (!secret) return null;
193
- try {
194
- const key = new TextEncoder().encode(secret);
195
- const { payload } = await jwtVerify(token, key, {
196
- issuer,
197
- audience,
198
- algorithms: ["HS256"]
199
- });
224
+ const validateLogoutPayload = (payload) => {
200
225
  if (payload.type !== "logout_token") {
201
226
  return null;
202
227
  }
@@ -208,14 +233,65 @@ function createTokenVerifier(options) {
208
233
  if (!jti || typeof jti !== "string") {
209
234
  return null;
210
235
  }
211
- if (processedLogoutJtis.has(jti)) {
236
+ const iss = payload.iss || issuer;
237
+ const jtiKey = `${iss}:${jti}`;
238
+ if (processedLogoutJtis.has(jtiKey)) {
212
239
  return null;
213
240
  }
214
- processedLogoutJtis.set(jti, Date.now());
241
+ processedLogoutJtis.set(jtiKey, Date.now());
215
242
  return payload;
243
+ };
244
+ let alg;
245
+ try {
246
+ alg = decodeProtectedHeader(token).alg;
216
247
  } catch {
217
248
  return null;
218
249
  }
250
+ if (alg === "RS256") {
251
+ const directKey = await getLogoutRS256PublicKey();
252
+ if (directKey) {
253
+ try {
254
+ const { payload } = await jwtVerify(token, directKey, {
255
+ issuer,
256
+ audience,
257
+ algorithms: ["RS256"]
258
+ });
259
+ return validateLogoutPayload(payload);
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+ const jwks = getJwksKeySet();
265
+ if (jwks) {
266
+ try {
267
+ const { payload } = await jwtVerify(token, jwks, {
268
+ issuer,
269
+ audience,
270
+ algorithms: ["RS256"]
271
+ });
272
+ return validateLogoutPayload(payload);
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ if (alg === "HS256") {
280
+ const secret = logoutTokenSecret || accessTokenSecret;
281
+ if (!secret) return null;
282
+ try {
283
+ const key = new TextEncoder().encode(secret);
284
+ const { payload } = await jwtVerify(token, key, {
285
+ issuer,
286
+ audience,
287
+ algorithms: ["HS256"]
288
+ });
289
+ return validateLogoutPayload(payload);
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+ return null;
219
295
  }
220
296
  };
221
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",