@nihplod/sso-verify 1.1.1 → 1.1.3

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) {
@@ -186,6 +187,13 @@ function createTokenVerifier(options) {
186
187
  }
187
188
  return data;
188
189
  }
190
+ function normalizeLocalPayload(payload) {
191
+ const p = payload;
192
+ if (!p.sub && typeof p.id === "string" && p.id) {
193
+ p.sub = p.id;
194
+ }
195
+ return p.sub ? p : null;
196
+ }
189
197
  async function verifyLocally(token) {
190
198
  if (!accessTokenSecret) return null;
191
199
  try {
@@ -199,7 +207,7 @@ function createTokenVerifier(options) {
199
207
  if (payload.type !== "access_token") {
200
208
  return null;
201
209
  }
202
- return payload;
210
+ return normalizeLocalPayload(payload);
203
211
  } catch {
204
212
  return null;
205
213
  }
@@ -218,7 +226,7 @@ function createTokenVerifier(options) {
218
226
  clockTolerance: clockToleranceSeconds
219
227
  });
220
228
  if (payload.type !== "access_token") return null;
221
- return payload;
229
+ return normalizeLocalPayload(payload);
222
230
  }
223
231
  const jwks = getJwksKeySet();
224
232
  if (jwks) {
@@ -229,7 +237,7 @@ function createTokenVerifier(options) {
229
237
  clockTolerance: clockToleranceSeconds
230
238
  });
231
239
  if (payload.type !== "access_token") return null;
232
- return payload;
240
+ return normalizeLocalPayload(payload);
233
241
  }
234
242
  return null;
235
243
  } catch {
@@ -285,6 +293,7 @@ function createTokenVerifier(options) {
285
293
  * 3. aud 包含当前 client_id
286
294
  * 4. exp 存在(规范要求)
287
295
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
296
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
288
297
  *
289
298
  * @returns LogoutTokenPayload 或 null(验证失败)
290
299
  */
@@ -305,6 +314,13 @@ function createTokenVerifier(options) {
305
314
  if (!jti || typeof jti !== "string") {
306
315
  return null;
307
316
  }
317
+ const sub = payload.sub;
318
+ const sid = payload.sid;
319
+ const hasSub = typeof sub === "string" && sub.length > 0;
320
+ const hasSid = typeof sid === "string" && sid.length > 0;
321
+ if (!hasSub && !hasSid) {
322
+ return null;
323
+ }
308
324
  const iss = payload.iss || issuer;
309
325
  const jtiKey = `${iss}:${jti}`;
310
326
  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) {
@@ -165,6 +166,13 @@ function createTokenVerifier(options) {
165
166
  }
166
167
  return data;
167
168
  }
169
+ function normalizeLocalPayload(payload) {
170
+ const p = payload;
171
+ if (!p.sub && typeof p.id === "string" && p.id) {
172
+ p.sub = p.id;
173
+ }
174
+ return p.sub ? p : null;
175
+ }
168
176
  async function verifyLocally(token) {
169
177
  if (!accessTokenSecret) return null;
170
178
  try {
@@ -178,7 +186,7 @@ function createTokenVerifier(options) {
178
186
  if (payload.type !== "access_token") {
179
187
  return null;
180
188
  }
181
- return payload;
189
+ return normalizeLocalPayload(payload);
182
190
  } catch {
183
191
  return null;
184
192
  }
@@ -197,7 +205,7 @@ function createTokenVerifier(options) {
197
205
  clockTolerance: clockToleranceSeconds
198
206
  });
199
207
  if (payload.type !== "access_token") return null;
200
- return payload;
208
+ return normalizeLocalPayload(payload);
201
209
  }
202
210
  const jwks = getJwksKeySet();
203
211
  if (jwks) {
@@ -208,7 +216,7 @@ function createTokenVerifier(options) {
208
216
  clockTolerance: clockToleranceSeconds
209
217
  });
210
218
  if (payload.type !== "access_token") return null;
211
- return payload;
219
+ return normalizeLocalPayload(payload);
212
220
  }
213
221
  return null;
214
222
  } catch {
@@ -264,6 +272,7 @@ function createTokenVerifier(options) {
264
272
  * 3. aud 包含当前 client_id
265
273
  * 4. exp 存在(规范要求)
266
274
  * 5. events 包含 backchannel-logout 事件,且事件值为对象
275
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
267
276
  *
268
277
  * @returns LogoutTokenPayload 或 null(验证失败)
269
278
  */
@@ -284,6 +293,13 @@ function createTokenVerifier(options) {
284
293
  if (!jti || typeof jti !== "string") {
285
294
  return null;
286
295
  }
296
+ const sub = payload.sub;
297
+ const sid = payload.sid;
298
+ const hasSub = typeof sub === "string" && sub.length > 0;
299
+ const hasSid = typeof sid === "string" && sid.length > 0;
300
+ if (!hasSub && !hasSid) {
301
+ return null;
302
+ }
287
303
  const iss = payload.iss || issuer;
288
304
  const jtiKey = `${iss}:${jti}`;
289
305
  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.3",
4
4
  "description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",