@nihplod/sso-verify 1.1.1 → 1.1.2

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,6 +104,7 @@ 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
108
 
108
109
  ### Revocation latency trade-off
109
110
 
@@ -119,11 +120,13 @@ When verifying via introspection, the response's audience binding is checked aga
119
120
  - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
120
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
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.
124
+
122
125
  ### Logout token verification
123
126
 
124
127
  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.
125
128
 
126
- Per OIDC Back-Channel Logout 1.0, a `logout_token` must carry an `exp` claim and an `events` claim whose `http://schemas.openid.net/event/backchannel-logout` member is an object (typically `{}`); tokens failing either check are rejected. Verified `jti`s are replay-guarded per issuer for 10 minutes.
129
+ Per OIDC Back-Channel Logout 1.0, a `logout_token` must carry an `exp` claim, an `events` claim whose `http://schemas.openid.net/event/backchannel-logout` member is an object (typically `{}`), and at least one of `sub` / `sid` (§2.4); tokens failing any of these checks are rejected. Verified `jti`s are replay-guarded per issuer for 10 minutes.
127
130
 
128
131
  When the IdP revokes sessions administratively (consent revoke, session termination, user ban/deletion), the `logout_token` also carries a `sid` claim matching the terminated IdP session — RPs that track IdP `sid`s should invalidate the matching local session and may fall back to `sub`-level logout when `sid` is absent (e.g. user-initiated IdP logout without a known session). Failed deliveries are retried by the IdP with exponential backoff (up to 10 attempts), so RPs may receive a delayed `logout_token`; the `jti` replay guard still applies.
129
132
 
package/dist/index.d.mts CHANGED
@@ -111,6 +111,17 @@ interface SsoVerifierOptions {
111
111
  * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
112
112
  */
113
113
  logoutJtiStore?: LogoutJtiStore;
114
+ /**
115
+ * Introspection audience 严格模式(默认 false,保持向后兼容)。
116
+ *
117
+ * 默认模式下,introspection 响应既无 aud 又无 client_id 时保持信任
118
+ * (兼容不返回归属字段的端点)。设为 true 后:响应缺少归属字段即拒绝
119
+ * (fail-closed),防止被 confused deputy 攻击利用。
120
+ *
121
+ * ⚠️ 生产环境强烈建议开启。主站 introspection 端点始终返回 client_id,
122
+ * 开启后行为不变;仅在对接不返回归属字段的第三方端点时才需要保持 false。
123
+ */
124
+ strictAudience?: boolean;
114
125
  }
115
126
  /**
116
127
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -136,7 +147,7 @@ interface VerifiedTokenPayload extends JWTPayload {
136
147
  * 主站签发 logout_token 时包含以下字段:
137
148
  * - iss: 主站 issuer
138
149
  * - aud: 目标 client_id
139
- * - sub: 用户 ID
150
+ * - sub: 用户 ID(与 sid 至少其一)
140
151
  * - iat: 签发时间
141
152
  * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
142
153
  * - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
@@ -148,8 +159,8 @@ interface LogoutTokenPayload {
148
159
  iss: string;
149
160
  /** 目标 audience */
150
161
  aud: string;
151
- /** 用户 ID */
152
- sub: string;
162
+ /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */
163
+ sub?: string;
153
164
  /** 签发时间(UNIX timestamp) */
154
165
  iat: number;
155
166
  /** JWT ID(唯一标识,防重放) */
@@ -200,6 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
200
211
  * 3. aud 包含当前 client_id
201
212
  * 4. exp 存在(规范要求)
202
213
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
214
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
203
215
  *
204
216
  * @returns LogoutTokenPayload 或 null(验证失败)
205
217
  */
package/dist/index.d.ts CHANGED
@@ -111,6 +111,17 @@ interface SsoVerifierOptions {
111
111
  * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
112
112
  */
113
113
  logoutJtiStore?: LogoutJtiStore;
114
+ /**
115
+ * Introspection audience 严格模式(默认 false,保持向后兼容)。
116
+ *
117
+ * 默认模式下,introspection 响应既无 aud 又无 client_id 时保持信任
118
+ * (兼容不返回归属字段的端点)。设为 true 后:响应缺少归属字段即拒绝
119
+ * (fail-closed),防止被 confused deputy 攻击利用。
120
+ *
121
+ * ⚠️ 生产环境强烈建议开启。主站 introspection 端点始终返回 client_id,
122
+ * 开启后行为不变;仅在对接不返回归属字段的第三方端点时才需要保持 false。
123
+ */
124
+ strictAudience?: boolean;
114
125
  }
115
126
  /**
116
127
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -136,7 +147,7 @@ interface VerifiedTokenPayload extends JWTPayload {
136
147
  * 主站签发 logout_token 时包含以下字段:
137
148
  * - iss: 主站 issuer
138
149
  * - aud: 目标 client_id
139
- * - sub: 用户 ID
150
+ * - sub: 用户 ID(与 sid 至少其一)
140
151
  * - iat: 签发时间
141
152
  * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
142
153
  * - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
@@ -148,8 +159,8 @@ interface LogoutTokenPayload {
148
159
  iss: string;
149
160
  /** 目标 audience */
150
161
  aud: string;
151
- /** 用户 ID */
152
- sub: string;
162
+ /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */
163
+ sub?: string;
153
164
  /** 签发时间(UNIX timestamp) */
154
165
  iat: number;
155
166
  /** JWT ID(唯一标识,防重放) */
@@ -200,6 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
200
211
  * 3. aud 包含当前 client_id
201
212
  * 4. exp 存在(规范要求)
202
213
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
214
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
203
215
  *
204
216
  * @returns LogoutTokenPayload 或 null(验证失败)
205
217
  */
package/dist/index.js CHANGED
@@ -54,7 +54,8 @@ function createTokenVerifier(options) {
54
54
  introspectNegativeCacheTtl = 5 * 1e3,
55
55
  introspectRetries = 1,
56
56
  clockToleranceSeconds = 60,
57
- logoutJtiStore
57
+ logoutJtiStore,
58
+ strictAudience = false
58
59
  } = options;
59
60
  const introspectCache = createIntrospectCache(introspectCacheTtl);
60
61
  let _jwksKeySet = null;
@@ -107,7 +108,7 @@ function createTokenVerifier(options) {
107
108
  if (typeof aud === "string") return aud === audience;
108
109
  if (Array.isArray(aud)) return aud.includes(audience);
109
110
  if (typeof data.client_id === "string") return data.client_id === audience;
110
- return true;
111
+ return !strictAudience;
111
112
  }
112
113
  const inflightIntrospects = /* @__PURE__ */ new Map();
113
114
  async function introspect(token) {
@@ -285,6 +286,7 @@ function createTokenVerifier(options) {
285
286
  * 3. aud 包含当前 client_id
286
287
  * 4. exp 存在(规范要求)
287
288
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
289
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
288
290
  *
289
291
  * @returns LogoutTokenPayload 或 null(验证失败)
290
292
  */
@@ -305,6 +307,13 @@ function createTokenVerifier(options) {
305
307
  if (!jti || typeof jti !== "string") {
306
308
  return null;
307
309
  }
310
+ const sub = payload.sub;
311
+ const sid = payload.sid;
312
+ const hasSub = typeof sub === "string" && sub.length > 0;
313
+ const hasSid = typeof sid === "string" && sid.length > 0;
314
+ if (!hasSub && !hasSid) {
315
+ return null;
316
+ }
308
317
  const iss = payload.iss || issuer;
309
318
  const jtiKey = `${iss}:${jti}`;
310
319
  if (logoutJtiStore) {
package/dist/index.mjs CHANGED
@@ -33,7 +33,8 @@ function createTokenVerifier(options) {
33
33
  introspectNegativeCacheTtl = 5 * 1e3,
34
34
  introspectRetries = 1,
35
35
  clockToleranceSeconds = 60,
36
- logoutJtiStore
36
+ logoutJtiStore,
37
+ strictAudience = false
37
38
  } = options;
38
39
  const introspectCache = createIntrospectCache(introspectCacheTtl);
39
40
  let _jwksKeySet = null;
@@ -86,7 +87,7 @@ function createTokenVerifier(options) {
86
87
  if (typeof aud === "string") return aud === audience;
87
88
  if (Array.isArray(aud)) return aud.includes(audience);
88
89
  if (typeof data.client_id === "string") return data.client_id === audience;
89
- return true;
90
+ return !strictAudience;
90
91
  }
91
92
  const inflightIntrospects = /* @__PURE__ */ new Map();
92
93
  async function introspect(token) {
@@ -264,6 +265,7 @@ function createTokenVerifier(options) {
264
265
  * 3. aud 包含当前 client_id
265
266
  * 4. exp 存在(规范要求)
266
267
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
268
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
267
269
  *
268
270
  * @returns LogoutTokenPayload 或 null(验证失败)
269
271
  */
@@ -284,6 +286,13 @@ function createTokenVerifier(options) {
284
286
  if (!jti || typeof jti !== "string") {
285
287
  return null;
286
288
  }
289
+ const sub = payload.sub;
290
+ const sid = payload.sid;
291
+ const hasSub = typeof sub === "string" && sub.length > 0;
292
+ const hasSid = typeof sid === "string" && sid.length > 0;
293
+ if (!hasSub && !hasSid) {
294
+ return null;
295
+ }
287
296
  const iss = payload.iss || issuer;
288
297
  const jtiKey = `${iss}:${jti}`;
289
298
  if (logoutJtiStore) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",