@nihplod/sso-verify 1.1.0 → 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
@@ -86,9 +86,9 @@ After successful verification, the token payload is attached to `req.user`.
86
86
 
87
87
  ## Notes
88
88
 
89
- - The main site currently uses HS256 symmetric signing by default. For external sub-projects, we recommend verifying access tokens via **Introspection**.
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
- - **Never distribute `JWT_ACCESS_SECRET` (the HS256 symmetric key) to external sub-projects.**
89
+ - The main site signs access tokens with **RS256 when `JWT_ACCESS_PRIVATE_KEY` is configured (recommended, enables local verification via JWKS)**, and falls back to HS256 symmetric signing when it is not. For external sub-projects, we recommend verifying access tokens via **Introspection** regardless of the signing algorithm — it always works and reflects revocations in real time.
90
+ - If your sub-project is an internal service and the main site has RS256 enabled, you can configure `accessTokenPublicKey` or `jwksUri` for local JWT verification.
91
+ - **Never distribute `JWT_ACCESS_SECRET` (the HS256 symmetric key) to external sub-projects.** `accessTokenSecret` local verification only applies to internal services that legitimately share the HS256 secret.
92
92
 
93
93
  ## Advanced Options
94
94
 
@@ -97,15 +97,53 @@ After successful verification, the token payload is attached to `req.user`.
97
97
  | `introspectCacheTtl` | `30000` | Cache TTL (ms) for `active: true` introspection results |
98
98
  | `introspectNegativeCacheTtl` | `5000` | Cache TTL (ms) for `active: false` results; set `0` to disable caching of revoked/invalid tokens |
99
99
  | `introspectTimeoutMs` | `10000` | Introspection request timeout (ms); a timeout is treated as verification failure |
100
+ | `introspectRetries` | `1` | Retries for introspection requests on network errors / 5xx (short backoff); 4xx is never retried; set `0` to disable |
101
+ | `clockToleranceSeconds` | `60` | Clock skew tolerance (seconds) applied to all local JWT verification paths (HS256 / RS256 / logout token) |
100
102
  | `accessTokenPublicKey` | — | RS256 public key (PEM) for local access token verification |
101
103
  | `jwksUri` | — | JWKS endpoint URL; public keys are matched by `kid` |
102
104
  | `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
103
105
  | `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
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** |
104
108
 
105
109
  ### Revocation latency trade-off
106
110
 
107
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.
108
112
 
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
+
115
+ ### Audience (aud) binding for introspection
116
+
117
+ When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
118
+
119
+ - If the response contains `aud` (string or array), it must include the configured `audience`.
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.
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
+
109
125
  ### Logout token verification
110
126
 
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.
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.
128
+
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.
130
+
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.
132
+
133
+ ### Multi-instance deployments (logout jti replay store)
134
+
135
+ By default, processed `logout_token` jtis are kept in an in-process LRU cache — it is emptied on restart and **not shared across instances**, so a replayed logout token could be accepted by another instance. When running multiple instances, inject a shared store (e.g. backed by Redis) via `logoutJtiStore`:
136
+
137
+ ```typescript
138
+ const verifier = createTokenVerifier({
139
+ audience: "your-client-id",
140
+ logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
141
+ logoutJtiStore: {
142
+ // Both sync and async implementations are supported.
143
+ has: (key) => redis.exists(key).then((n) => n > 0),
144
+ add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
145
+ },
146
+ });
147
+ ```
148
+
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).
package/dist/index.d.mts CHANGED
@@ -7,10 +7,11 @@ import { JWTPayload } from 'jose';
7
7
  *
8
8
  * 供子项目在后端验证主站签发的 Access Token。
9
9
  *
10
- * 由于主站当前使用 HS256 对称签名,JWKS 端点不公开签名密钥,
11
- * 因此本工具包默认采用 OAuth 2.0 Token Introspection(RFC 7662)
12
- * 端点进行验证。对于已知共享密钥的内部服务,也可传入
13
- * accessTokenSecret 做本地 JWT 验证。
10
+ * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY
11
+ * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256
12
+ * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection
13
+ * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入
14
+ * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。
14
15
  *
15
16
  * 安装:npm install @nihplod/sso-verify
16
17
  *
@@ -38,7 +39,7 @@ interface SsoVerifierOptions {
38
39
  issuer?: string;
39
40
  /**
40
41
  * Token Introspection 端点 URL。
41
- * 推荐优先使用,适配当前 HS256 对称签名场景。
42
+ * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。
42
43
  */
43
44
  introspectionEndpoint?: string;
44
45
  /** 子项目的 client_id(用于 introspection) */
@@ -66,8 +67,9 @@ interface SsoVerifierOptions {
66
67
  introspectNegativeCacheTtl?: number;
67
68
  /**
68
69
  * Access Token RS256 公钥(PEM 格式,可选)。
69
- * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
70
- * 避免每次都调用 Introspection 端点。
70
+ * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
+ * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
+ * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
71
73
  */
72
74
  accessTokenPublicKey?: string;
73
75
  /**
@@ -91,6 +93,45 @@ interface SsoVerifierOptions {
91
93
  * 不会静默回退到 HS256。
92
94
  */
93
95
  logoutTokenPublicKey?: string;
96
+ /**
97
+ * Introspection 请求失败重试次数,默认 1。
98
+ * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
99
+ * 设为 0 表示不重试。
100
+ */
101
+ introspectRetries?: number;
102
+ /**
103
+ * JWT 时钟偏移容忍(秒),默认 60。
104
+ * 应用于所有本地验签路径(HS256/RS256/logout token),
105
+ * 用于容忍子项目与主站之间的时钟偏差。
106
+ */
107
+ clockToleranceSeconds?: number;
108
+ /**
109
+ * Logout Token jti 外部存储(可选)。
110
+ * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);
111
+ * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
112
+ */
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;
125
+ }
126
+ /**
127
+ * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
128
+ * has/add 均支持同步或异步返回。
129
+ */
130
+ interface LogoutJtiStore {
131
+ /** 判断 jti 是否已处理过 */
132
+ has(key: string): boolean | Promise<boolean>;
133
+ /** 记录已处理的 jti,ttlSeconds 后过期 */
134
+ add(key: string, ttlSeconds: number): void | Promise<void>;
94
135
  }
95
136
  interface VerifiedTokenPayload extends JWTPayload {
96
137
  sub: string;
@@ -106,7 +147,7 @@ interface VerifiedTokenPayload extends JWTPayload {
106
147
  * 主站签发 logout_token 时包含以下字段:
107
148
  * - iss: 主站 issuer
108
149
  * - aud: 目标 client_id
109
- * - sub: 用户 ID
150
+ * - sub: 用户 ID(与 sid 至少其一)
110
151
  * - iat: 签发时间
111
152
  * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
112
153
  * - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
@@ -118,8 +159,8 @@ interface LogoutTokenPayload {
118
159
  iss: string;
119
160
  /** 目标 audience */
120
161
  aud: string;
121
- /** 用户 ID */
122
- sub: string;
162
+ /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */
163
+ sub?: string;
123
164
  /** 签发时间(UNIX timestamp) */
124
165
  iat: number;
125
166
  /** JWT ID(唯一标识,防重放) */
@@ -134,6 +175,7 @@ interface LogoutTokenPayload {
134
175
  interface IntrospectResponse {
135
176
  active: boolean;
136
177
  sub?: string;
178
+ aud?: string | string[];
137
179
  client_id?: string;
138
180
  scope?: string;
139
181
  exp?: number;
@@ -167,7 +209,9 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
167
209
  * 1. type === "logout_token"
168
210
  * 2. iss 匹配配置的 issuer
169
211
  * 3. aud 包含当前 client_id
170
- * 4. events 包含 backchannel-logout 事件
212
+ * 4. exp 存在(规范要求)
213
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
214
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
171
215
  *
172
216
  * @returns LogoutTokenPayload 或 null(验证失败)
173
217
  */
@@ -211,7 +255,7 @@ interface SsoMiddlewareResponse {
211
255
  * }
212
256
  * ```
213
257
  */
214
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
258
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
215
259
  /**
216
260
  * 创建 Logout Token 专用验证器
217
261
  *
@@ -248,4 +292,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
248
292
  verify(token: string): Promise<LogoutTokenPayload | null>;
249
293
  };
250
294
 
251
- export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
295
+ export { type LogoutJtiStore, type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
package/dist/index.d.ts CHANGED
@@ -7,10 +7,11 @@ import { JWTPayload } from 'jose';
7
7
  *
8
8
  * 供子项目在后端验证主站签发的 Access Token。
9
9
  *
10
- * 由于主站当前使用 HS256 对称签名,JWKS 端点不公开签名密钥,
11
- * 因此本工具包默认采用 OAuth 2.0 Token Introspection(RFC 7662)
12
- * 端点进行验证。对于已知共享密钥的内部服务,也可传入
13
- * accessTokenSecret 做本地 JWT 验证。
10
+ * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY
11
+ * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256
12
+ * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection
13
+ * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入
14
+ * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。
14
15
  *
15
16
  * 安装:npm install @nihplod/sso-verify
16
17
  *
@@ -38,7 +39,7 @@ interface SsoVerifierOptions {
38
39
  issuer?: string;
39
40
  /**
40
41
  * Token Introspection 端点 URL。
41
- * 推荐优先使用,适配当前 HS256 对称签名场景。
42
+ * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。
42
43
  */
43
44
  introspectionEndpoint?: string;
44
45
  /** 子项目的 client_id(用于 introspection) */
@@ -66,8 +67,9 @@ interface SsoVerifierOptions {
66
67
  introspectNegativeCacheTtl?: number;
67
68
  /**
68
69
  * Access Token RS256 公钥(PEM 格式,可选)。
69
- * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
70
- * 避免每次都调用 Introspection 端点。
70
+ * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
+ * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
+ * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
71
73
  */
72
74
  accessTokenPublicKey?: string;
73
75
  /**
@@ -91,6 +93,45 @@ interface SsoVerifierOptions {
91
93
  * 不会静默回退到 HS256。
92
94
  */
93
95
  logoutTokenPublicKey?: string;
96
+ /**
97
+ * Introspection 请求失败重试次数,默认 1。
98
+ * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
99
+ * 设为 0 表示不重试。
100
+ */
101
+ introspectRetries?: number;
102
+ /**
103
+ * JWT 时钟偏移容忍(秒),默认 60。
104
+ * 应用于所有本地验签路径(HS256/RS256/logout token),
105
+ * 用于容忍子项目与主站之间的时钟偏差。
106
+ */
107
+ clockToleranceSeconds?: number;
108
+ /**
109
+ * Logout Token jti 外部存储(可选)。
110
+ * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);
111
+ * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
112
+ */
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;
125
+ }
126
+ /**
127
+ * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
128
+ * has/add 均支持同步或异步返回。
129
+ */
130
+ interface LogoutJtiStore {
131
+ /** 判断 jti 是否已处理过 */
132
+ has(key: string): boolean | Promise<boolean>;
133
+ /** 记录已处理的 jti,ttlSeconds 后过期 */
134
+ add(key: string, ttlSeconds: number): void | Promise<void>;
94
135
  }
95
136
  interface VerifiedTokenPayload extends JWTPayload {
96
137
  sub: string;
@@ -106,7 +147,7 @@ interface VerifiedTokenPayload extends JWTPayload {
106
147
  * 主站签发 logout_token 时包含以下字段:
107
148
  * - iss: 主站 issuer
108
149
  * - aud: 目标 client_id
109
- * - sub: 用户 ID
150
+ * - sub: 用户 ID(与 sid 至少其一)
110
151
  * - iat: 签发时间
111
152
  * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
112
153
  * - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
@@ -118,8 +159,8 @@ interface LogoutTokenPayload {
118
159
  iss: string;
119
160
  /** 目标 audience */
120
161
  aud: string;
121
- /** 用户 ID */
122
- sub: string;
162
+ /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */
163
+ sub?: string;
123
164
  /** 签发时间(UNIX timestamp) */
124
165
  iat: number;
125
166
  /** JWT ID(唯一标识,防重放) */
@@ -134,6 +175,7 @@ interface LogoutTokenPayload {
134
175
  interface IntrospectResponse {
135
176
  active: boolean;
136
177
  sub?: string;
178
+ aud?: string | string[];
137
179
  client_id?: string;
138
180
  scope?: string;
139
181
  exp?: number;
@@ -167,7 +209,9 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
167
209
  * 1. type === "logout_token"
168
210
  * 2. iss 匹配配置的 issuer
169
211
  * 3. aud 包含当前 client_id
170
- * 4. events 包含 backchannel-logout 事件
212
+ * 4. exp 存在(规范要求)
213
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
214
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
171
215
  *
172
216
  * @returns LogoutTokenPayload 或 null(验证失败)
173
217
  */
@@ -211,7 +255,7 @@ interface SsoMiddlewareResponse {
211
255
  * }
212
256
  * ```
213
257
  */
214
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
258
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
215
259
  /**
216
260
  * 创建 Logout Token 专用验证器
217
261
  *
@@ -248,4 +292,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
248
292
  verify(token: string): Promise<LogoutTokenPayload | null>;
249
293
  };
250
294
 
251
- export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
295
+ export { type LogoutJtiStore, type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
package/dist/index.js CHANGED
@@ -51,7 +51,11 @@ function createTokenVerifier(options) {
51
51
  logoutTokenPublicKey,
52
52
  introspectCacheTtl = 30 * 1e3,
53
53
  introspectTimeoutMs = 10 * 1e3,
54
- introspectNegativeCacheTtl = 5 * 1e3
54
+ introspectNegativeCacheTtl = 5 * 1e3,
55
+ introspectRetries = 1,
56
+ clockToleranceSeconds = 60,
57
+ logoutJtiStore,
58
+ strictAudience = false
55
59
  } = options;
56
60
  const introspectCache = createIntrospectCache(introspectCacheTtl);
57
61
  let _jwksKeySet = null;
@@ -99,6 +103,14 @@ function createTokenVerifier(options) {
99
103
  }
100
104
  return _logoutRs256PublicKey;
101
105
  }
106
+ function matchesAudience(data) {
107
+ const aud = data.aud;
108
+ if (typeof aud === "string") return aud === audience;
109
+ if (Array.isArray(aud)) return aud.includes(audience);
110
+ if (typeof data.client_id === "string") return data.client_id === audience;
111
+ return !strictAudience;
112
+ }
113
+ const inflightIntrospects = /* @__PURE__ */ new Map();
102
114
  async function introspect(token) {
103
115
  if (!introspectionEndpoint || !clientId) {
104
116
  return null;
@@ -107,41 +119,73 @@ function createTokenVerifier(options) {
107
119
  if (cached) {
108
120
  return { active: cached.active, ...cached.payload || {} };
109
121
  }
110
- try {
111
- const params = new URLSearchParams({ token, client_id: clientId });
112
- if (clientSecret) {
113
- params.set("client_secret", clientSecret);
122
+ const inflight = inflightIntrospects.get(token);
123
+ if (inflight) {
124
+ return inflight;
125
+ }
126
+ const promise = doIntrospect(token).finally(() => {
127
+ inflightIntrospects.delete(token);
128
+ });
129
+ inflightIntrospects.set(token, promise);
130
+ return promise;
131
+ }
132
+ async function doIntrospect(token) {
133
+ const params = new URLSearchParams({ token, client_id: clientId });
134
+ if (clientSecret) {
135
+ params.set("client_secret", clientSecret);
136
+ }
137
+ let data = null;
138
+ for (let attempt = 0; ; attempt++) {
139
+ if (attempt > 0) {
140
+ await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
114
141
  }
115
- const response = await fetch(introspectionEndpoint, {
116
- method: "POST",
117
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
118
- body: params,
119
- signal: AbortSignal.timeout(introspectTimeoutMs)
120
- });
121
- if (!response.ok) return null;
122
- const data = await response.json();
123
- let payload = null;
124
- if (data.active && data.sub) {
125
- payload = {
126
- sub: data.sub,
127
- aud: audience,
128
- iss: issuer,
129
- client_id: data.client_id,
130
- scope: data.scope,
131
- exp: data.exp
132
- };
142
+ let response;
143
+ try {
144
+ response = await fetch(introspectionEndpoint, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
147
+ body: params,
148
+ signal: AbortSignal.timeout(introspectTimeoutMs)
149
+ });
150
+ } catch {
151
+ if (attempt < introspectRetries) continue;
152
+ return null;
153
+ }
154
+ if (!response.ok) {
155
+ if (response.status >= 500 && attempt < introspectRetries) continue;
156
+ return null;
133
157
  }
134
- if (data.active) {
135
- introspectCache.set(token, { active: true, payload });
136
- } else if (introspectNegativeCacheTtl > 0) {
158
+ data = await response.json();
159
+ break;
160
+ }
161
+ if (!data) return null;
162
+ if (data.active && !matchesAudience(data)) {
163
+ if (introspectNegativeCacheTtl > 0) {
137
164
  introspectCache.set(token, { active: false, payload: null }, {
138
165
  ttl: introspectNegativeCacheTtl
139
166
  });
140
167
  }
141
- return data;
142
- } catch {
143
- return null;
168
+ return { active: false };
144
169
  }
170
+ let payload = null;
171
+ if (data.active && data.sub) {
172
+ payload = {
173
+ sub: data.sub,
174
+ aud: audience,
175
+ iss: issuer,
176
+ client_id: data.client_id,
177
+ scope: data.scope,
178
+ exp: data.exp
179
+ };
180
+ }
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
+ });
187
+ }
188
+ return data;
145
189
  }
146
190
  async function verifyLocally(token) {
147
191
  if (!accessTokenSecret) return null;
@@ -150,7 +194,8 @@ function createTokenVerifier(options) {
150
194
  const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
151
195
  issuer,
152
196
  audience,
153
- algorithms: ["HS256"]
197
+ algorithms: ["HS256"],
198
+ clockTolerance: clockToleranceSeconds
154
199
  });
155
200
  if (payload.type !== "access_token") {
156
201
  return null;
@@ -170,7 +215,8 @@ function createTokenVerifier(options) {
170
215
  const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
171
216
  issuer,
172
217
  audience,
173
- algorithms: ["RS256"]
218
+ algorithms: ["RS256"],
219
+ clockTolerance: clockToleranceSeconds
174
220
  });
175
221
  if (payload.type !== "access_token") return null;
176
222
  return payload;
@@ -180,7 +226,8 @@ function createTokenVerifier(options) {
180
226
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
181
227
  issuer,
182
228
  audience,
183
- algorithms: ["RS256"]
229
+ algorithms: ["RS256"],
230
+ clockTolerance: clockToleranceSeconds
184
231
  });
185
232
  if (payload.type !== "access_token") return null;
186
233
  return payload;
@@ -237,29 +284,49 @@ function createTokenVerifier(options) {
237
284
  * 1. type === "logout_token"
238
285
  * 2. iss 匹配配置的 issuer
239
286
  * 3. aud 包含当前 client_id
240
- * 4. events 包含 backchannel-logout 事件
287
+ * 4. exp 存在(规范要求)
288
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
289
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
241
290
  *
242
291
  * @returns LogoutTokenPayload 或 null(验证失败)
243
292
  */
244
293
  async verifyLogoutToken(token) {
245
- const validateLogoutPayload = (payload) => {
294
+ const validateLogoutPayload = async (payload) => {
246
295
  if (payload.type !== "logout_token") {
247
296
  return null;
248
297
  }
298
+ if (typeof payload.exp !== "number") {
299
+ return null;
300
+ }
249
301
  const events = payload.events;
250
- if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
302
+ const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
303
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
251
304
  return null;
252
305
  }
253
306
  const jti = payload.jti;
254
307
  if (!jti || typeof jti !== "string") {
255
308
  return null;
256
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
+ }
257
317
  const iss = payload.iss || issuer;
258
318
  const jtiKey = `${iss}:${jti}`;
259
- if (processedLogoutJtis.has(jtiKey)) {
260
- return null;
319
+ if (logoutJtiStore) {
320
+ if (await logoutJtiStore.has(jtiKey)) {
321
+ return null;
322
+ }
323
+ await logoutJtiStore.add(jtiKey, 10 * 60);
324
+ } else {
325
+ if (processedLogoutJtis.has(jtiKey)) {
326
+ return null;
327
+ }
328
+ processedLogoutJtis.set(jtiKey, Date.now());
261
329
  }
262
- processedLogoutJtis.set(jtiKey, Date.now());
263
330
  return payload;
264
331
  };
265
332
  let alg;
@@ -275,9 +342,10 @@ function createTokenVerifier(options) {
275
342
  const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
276
343
  issuer,
277
344
  audience,
278
- algorithms: ["RS256"]
345
+ algorithms: ["RS256"],
346
+ clockTolerance: clockToleranceSeconds
279
347
  });
280
- return validateLogoutPayload(payload);
348
+ return await validateLogoutPayload(payload);
281
349
  } catch {
282
350
  return null;
283
351
  }
@@ -288,9 +356,10 @@ function createTokenVerifier(options) {
288
356
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
289
357
  issuer,
290
358
  audience,
291
- algorithms: ["RS256"]
359
+ algorithms: ["RS256"],
360
+ clockTolerance: clockToleranceSeconds
292
361
  });
293
- return validateLogoutPayload(payload);
362
+ return await validateLogoutPayload(payload);
294
363
  } catch {
295
364
  return null;
296
365
  }
@@ -305,9 +374,10 @@ function createTokenVerifier(options) {
305
374
  const { payload } = await (0, import_jose.jwtVerify)(token, key, {
306
375
  issuer,
307
376
  audience,
308
- algorithms: ["HS256"]
377
+ algorithms: ["HS256"],
378
+ clockTolerance: clockToleranceSeconds
309
379
  });
310
- return validateLogoutPayload(payload);
380
+ return await validateLogoutPayload(payload);
311
381
  } catch {
312
382
  return null;
313
383
  }
@@ -324,14 +394,18 @@ function ssoMiddleware(options) {
324
394
  if (!token) {
325
395
  if (res.status) {
326
396
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
397
+ return;
327
398
  }
399
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
328
400
  return;
329
401
  }
330
402
  const payload = await verifier.verify(token);
331
403
  if (!payload) {
332
404
  if (res.status) {
333
405
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
406
+ return;
334
407
  }
408
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
335
409
  return;
336
410
  }
337
411
  req.user = payload;
package/dist/index.mjs CHANGED
@@ -30,7 +30,11 @@ function createTokenVerifier(options) {
30
30
  logoutTokenPublicKey,
31
31
  introspectCacheTtl = 30 * 1e3,
32
32
  introspectTimeoutMs = 10 * 1e3,
33
- introspectNegativeCacheTtl = 5 * 1e3
33
+ introspectNegativeCacheTtl = 5 * 1e3,
34
+ introspectRetries = 1,
35
+ clockToleranceSeconds = 60,
36
+ logoutJtiStore,
37
+ strictAudience = false
34
38
  } = options;
35
39
  const introspectCache = createIntrospectCache(introspectCacheTtl);
36
40
  let _jwksKeySet = null;
@@ -78,6 +82,14 @@ function createTokenVerifier(options) {
78
82
  }
79
83
  return _logoutRs256PublicKey;
80
84
  }
85
+ function matchesAudience(data) {
86
+ const aud = data.aud;
87
+ if (typeof aud === "string") return aud === audience;
88
+ if (Array.isArray(aud)) return aud.includes(audience);
89
+ if (typeof data.client_id === "string") return data.client_id === audience;
90
+ return !strictAudience;
91
+ }
92
+ const inflightIntrospects = /* @__PURE__ */ new Map();
81
93
  async function introspect(token) {
82
94
  if (!introspectionEndpoint || !clientId) {
83
95
  return null;
@@ -86,41 +98,73 @@ function createTokenVerifier(options) {
86
98
  if (cached) {
87
99
  return { active: cached.active, ...cached.payload || {} };
88
100
  }
89
- try {
90
- const params = new URLSearchParams({ token, client_id: clientId });
91
- if (clientSecret) {
92
- params.set("client_secret", clientSecret);
101
+ const inflight = inflightIntrospects.get(token);
102
+ if (inflight) {
103
+ return inflight;
104
+ }
105
+ const promise = doIntrospect(token).finally(() => {
106
+ inflightIntrospects.delete(token);
107
+ });
108
+ inflightIntrospects.set(token, promise);
109
+ return promise;
110
+ }
111
+ async function doIntrospect(token) {
112
+ const params = new URLSearchParams({ token, client_id: clientId });
113
+ if (clientSecret) {
114
+ params.set("client_secret", clientSecret);
115
+ }
116
+ let data = null;
117
+ for (let attempt = 0; ; attempt++) {
118
+ if (attempt > 0) {
119
+ await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
93
120
  }
94
- const response = await fetch(introspectionEndpoint, {
95
- method: "POST",
96
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
97
- body: params,
98
- signal: AbortSignal.timeout(introspectTimeoutMs)
99
- });
100
- if (!response.ok) return null;
101
- const data = await response.json();
102
- let payload = null;
103
- if (data.active && data.sub) {
104
- payload = {
105
- sub: data.sub,
106
- aud: audience,
107
- iss: issuer,
108
- client_id: data.client_id,
109
- scope: data.scope,
110
- exp: data.exp
111
- };
121
+ let response;
122
+ try {
123
+ response = await fetch(introspectionEndpoint, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: params,
127
+ signal: AbortSignal.timeout(introspectTimeoutMs)
128
+ });
129
+ } catch {
130
+ if (attempt < introspectRetries) continue;
131
+ return null;
132
+ }
133
+ if (!response.ok) {
134
+ if (response.status >= 500 && attempt < introspectRetries) continue;
135
+ return null;
112
136
  }
113
- if (data.active) {
114
- introspectCache.set(token, { active: true, payload });
115
- } else if (introspectNegativeCacheTtl > 0) {
137
+ data = await response.json();
138
+ break;
139
+ }
140
+ if (!data) return null;
141
+ if (data.active && !matchesAudience(data)) {
142
+ if (introspectNegativeCacheTtl > 0) {
116
143
  introspectCache.set(token, { active: false, payload: null }, {
117
144
  ttl: introspectNegativeCacheTtl
118
145
  });
119
146
  }
120
- return data;
121
- } catch {
122
- return null;
147
+ return { active: false };
123
148
  }
149
+ let payload = null;
150
+ if (data.active && data.sub) {
151
+ payload = {
152
+ sub: data.sub,
153
+ aud: audience,
154
+ iss: issuer,
155
+ client_id: data.client_id,
156
+ scope: data.scope,
157
+ exp: data.exp
158
+ };
159
+ }
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
+ });
166
+ }
167
+ return data;
124
168
  }
125
169
  async function verifyLocally(token) {
126
170
  if (!accessTokenSecret) return null;
@@ -129,7 +173,8 @@ function createTokenVerifier(options) {
129
173
  const { payload } = await jwtVerify(token, secret, {
130
174
  issuer,
131
175
  audience,
132
- algorithms: ["HS256"]
176
+ algorithms: ["HS256"],
177
+ clockTolerance: clockToleranceSeconds
133
178
  });
134
179
  if (payload.type !== "access_token") {
135
180
  return null;
@@ -149,7 +194,8 @@ function createTokenVerifier(options) {
149
194
  const { payload } = await jwtVerify(token, directKey, {
150
195
  issuer,
151
196
  audience,
152
- algorithms: ["RS256"]
197
+ algorithms: ["RS256"],
198
+ clockTolerance: clockToleranceSeconds
153
199
  });
154
200
  if (payload.type !== "access_token") return null;
155
201
  return payload;
@@ -159,7 +205,8 @@ function createTokenVerifier(options) {
159
205
  const { payload } = await jwtVerify(token, jwks, {
160
206
  issuer,
161
207
  audience,
162
- algorithms: ["RS256"]
208
+ algorithms: ["RS256"],
209
+ clockTolerance: clockToleranceSeconds
163
210
  });
164
211
  if (payload.type !== "access_token") return null;
165
212
  return payload;
@@ -216,29 +263,49 @@ function createTokenVerifier(options) {
216
263
  * 1. type === "logout_token"
217
264
  * 2. iss 匹配配置的 issuer
218
265
  * 3. aud 包含当前 client_id
219
- * 4. events 包含 backchannel-logout 事件
266
+ * 4. exp 存在(规范要求)
267
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
268
+ * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)
220
269
  *
221
270
  * @returns LogoutTokenPayload 或 null(验证失败)
222
271
  */
223
272
  async verifyLogoutToken(token) {
224
- const validateLogoutPayload = (payload) => {
273
+ const validateLogoutPayload = async (payload) => {
225
274
  if (payload.type !== "logout_token") {
226
275
  return null;
227
276
  }
277
+ if (typeof payload.exp !== "number") {
278
+ return null;
279
+ }
228
280
  const events = payload.events;
229
- if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
281
+ const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
282
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
230
283
  return null;
231
284
  }
232
285
  const jti = payload.jti;
233
286
  if (!jti || typeof jti !== "string") {
234
287
  return null;
235
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
+ }
236
296
  const iss = payload.iss || issuer;
237
297
  const jtiKey = `${iss}:${jti}`;
238
- if (processedLogoutJtis.has(jtiKey)) {
239
- return null;
298
+ if (logoutJtiStore) {
299
+ if (await logoutJtiStore.has(jtiKey)) {
300
+ return null;
301
+ }
302
+ await logoutJtiStore.add(jtiKey, 10 * 60);
303
+ } else {
304
+ if (processedLogoutJtis.has(jtiKey)) {
305
+ return null;
306
+ }
307
+ processedLogoutJtis.set(jtiKey, Date.now());
240
308
  }
241
- processedLogoutJtis.set(jtiKey, Date.now());
242
309
  return payload;
243
310
  };
244
311
  let alg;
@@ -254,9 +321,10 @@ function createTokenVerifier(options) {
254
321
  const { payload } = await jwtVerify(token, directKey, {
255
322
  issuer,
256
323
  audience,
257
- algorithms: ["RS256"]
324
+ algorithms: ["RS256"],
325
+ clockTolerance: clockToleranceSeconds
258
326
  });
259
- return validateLogoutPayload(payload);
327
+ return await validateLogoutPayload(payload);
260
328
  } catch {
261
329
  return null;
262
330
  }
@@ -267,9 +335,10 @@ function createTokenVerifier(options) {
267
335
  const { payload } = await jwtVerify(token, jwks, {
268
336
  issuer,
269
337
  audience,
270
- algorithms: ["RS256"]
338
+ algorithms: ["RS256"],
339
+ clockTolerance: clockToleranceSeconds
271
340
  });
272
- return validateLogoutPayload(payload);
341
+ return await validateLogoutPayload(payload);
273
342
  } catch {
274
343
  return null;
275
344
  }
@@ -284,9 +353,10 @@ function createTokenVerifier(options) {
284
353
  const { payload } = await jwtVerify(token, key, {
285
354
  issuer,
286
355
  audience,
287
- algorithms: ["HS256"]
356
+ algorithms: ["HS256"],
357
+ clockTolerance: clockToleranceSeconds
288
358
  });
289
- return validateLogoutPayload(payload);
359
+ return await validateLogoutPayload(payload);
290
360
  } catch {
291
361
  return null;
292
362
  }
@@ -303,14 +373,18 @@ function ssoMiddleware(options) {
303
373
  if (!token) {
304
374
  if (res.status) {
305
375
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
376
+ return;
306
377
  }
378
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
307
379
  return;
308
380
  }
309
381
  const payload = await verifier.verify(token);
310
382
  if (!payload) {
311
383
  if (res.status) {
312
384
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
385
+ return;
313
386
  }
387
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
314
388
  return;
315
389
  }
316
390
  req.user = payload;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.1.0",
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",
@@ -34,9 +34,12 @@
34
34
  "license": "MIT",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/nihplod/nihplod.cn",
37
+ "url": "https://github.com/Wilboerht/NIHPLOD-Homepage.CN",
38
38
  "directory": "packages/sso-verify"
39
39
  },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
40
43
  "peerDependencies": {},
41
44
  "dependencies": {
42
45
  "jose": "^5.0.0",