@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 +4 -1
- package/dist/index.d.mts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.js +21 -5
- package/dist/index.mjs +21 -5
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|