@nihplod/sso-verify 1.1.0 → 1.1.1

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,50 @@ 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) |
104
107
 
105
108
  ### Revocation latency trade-off
106
109
 
107
110
  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
111
 
112
+ 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`.
113
+
114
+ ### Audience (aud) binding for introspection
115
+
116
+ When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
117
+
118
+ - If the response contains `aud` (string or array), it must include the configured `audience`.
119
+ - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
120
+ - 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
+
109
122
  ### Logout token verification
110
123
 
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.
124
+ 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
+
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.
127
+
128
+ 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
+
130
+ ### Multi-instance deployments (logout jti replay store)
131
+
132
+ 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`:
133
+
134
+ ```typescript
135
+ const verifier = createTokenVerifier({
136
+ audience: "your-client-id",
137
+ logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
138
+ logoutJtiStore: {
139
+ // Both sync and async implementations are supported.
140
+ has: (key) => redis.exists(key).then((n) => n > 0),
141
+ add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
142
+ },
143
+ });
144
+ ```
145
+
146
+ `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,34 @@ 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
+ /**
116
+ * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
117
+ * has/add 均支持同步或异步返回。
118
+ */
119
+ interface LogoutJtiStore {
120
+ /** 判断 jti 是否已处理过 */
121
+ has(key: string): boolean | Promise<boolean>;
122
+ /** 记录已处理的 jti,ttlSeconds 后过期 */
123
+ add(key: string, ttlSeconds: number): void | Promise<void>;
94
124
  }
95
125
  interface VerifiedTokenPayload extends JWTPayload {
96
126
  sub: string;
@@ -134,6 +164,7 @@ interface LogoutTokenPayload {
134
164
  interface IntrospectResponse {
135
165
  active: boolean;
136
166
  sub?: string;
167
+ aud?: string | string[];
137
168
  client_id?: string;
138
169
  scope?: string;
139
170
  exp?: number;
@@ -167,7 +198,8 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
167
198
  * 1. type === "logout_token"
168
199
  * 2. iss 匹配配置的 issuer
169
200
  * 3. aud 包含当前 client_id
170
- * 4. events 包含 backchannel-logout 事件
201
+ * 4. exp 存在(规范要求)
202
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
171
203
  *
172
204
  * @returns LogoutTokenPayload 或 null(验证失败)
173
205
  */
@@ -211,7 +243,7 @@ interface SsoMiddlewareResponse {
211
243
  * }
212
244
  * ```
213
245
  */
214
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
246
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
215
247
  /**
216
248
  * 创建 Logout Token 专用验证器
217
249
  *
@@ -248,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
248
280
  verify(token: string): Promise<LogoutTokenPayload | null>;
249
281
  };
250
282
 
251
- export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
283
+ 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,34 @@ 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
+ /**
116
+ * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
117
+ * has/add 均支持同步或异步返回。
118
+ */
119
+ interface LogoutJtiStore {
120
+ /** 判断 jti 是否已处理过 */
121
+ has(key: string): boolean | Promise<boolean>;
122
+ /** 记录已处理的 jti,ttlSeconds 后过期 */
123
+ add(key: string, ttlSeconds: number): void | Promise<void>;
94
124
  }
95
125
  interface VerifiedTokenPayload extends JWTPayload {
96
126
  sub: string;
@@ -134,6 +164,7 @@ interface LogoutTokenPayload {
134
164
  interface IntrospectResponse {
135
165
  active: boolean;
136
166
  sub?: string;
167
+ aud?: string | string[];
137
168
  client_id?: string;
138
169
  scope?: string;
139
170
  exp?: number;
@@ -167,7 +198,8 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
167
198
  * 1. type === "logout_token"
168
199
  * 2. iss 匹配配置的 issuer
169
200
  * 3. aud 包含当前 client_id
170
- * 4. events 包含 backchannel-logout 事件
201
+ * 4. exp 存在(规范要求)
202
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
171
203
  *
172
204
  * @returns LogoutTokenPayload 或 null(验证失败)
173
205
  */
@@ -211,7 +243,7 @@ interface SsoMiddlewareResponse {
211
243
  * }
212
244
  * ```
213
245
  */
214
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
246
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
215
247
  /**
216
248
  * 创建 Logout Token 专用验证器
217
249
  *
@@ -248,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
248
280
  verify(token: string): Promise<LogoutTokenPayload | null>;
249
281
  };
250
282
 
251
- export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
283
+ export { type LogoutJtiStore, type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
package/dist/index.js CHANGED
@@ -51,7 +51,10 @@ 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
55
58
  } = options;
56
59
  const introspectCache = createIntrospectCache(introspectCacheTtl);
57
60
  let _jwksKeySet = null;
@@ -99,6 +102,14 @@ function createTokenVerifier(options) {
99
102
  }
100
103
  return _logoutRs256PublicKey;
101
104
  }
105
+ function matchesAudience(data) {
106
+ const aud = data.aud;
107
+ if (typeof aud === "string") return aud === audience;
108
+ if (Array.isArray(aud)) return aud.includes(audience);
109
+ if (typeof data.client_id === "string") return data.client_id === audience;
110
+ return true;
111
+ }
112
+ const inflightIntrospects = /* @__PURE__ */ new Map();
102
113
  async function introspect(token) {
103
114
  if (!introspectionEndpoint || !clientId) {
104
115
  return null;
@@ -107,41 +118,73 @@ function createTokenVerifier(options) {
107
118
  if (cached) {
108
119
  return { active: cached.active, ...cached.payload || {} };
109
120
  }
110
- try {
111
- const params = new URLSearchParams({ token, client_id: clientId });
112
- if (clientSecret) {
113
- params.set("client_secret", clientSecret);
121
+ const inflight = inflightIntrospects.get(token);
122
+ if (inflight) {
123
+ return inflight;
124
+ }
125
+ const promise = doIntrospect(token).finally(() => {
126
+ inflightIntrospects.delete(token);
127
+ });
128
+ inflightIntrospects.set(token, promise);
129
+ return promise;
130
+ }
131
+ async function doIntrospect(token) {
132
+ const params = new URLSearchParams({ token, client_id: clientId });
133
+ if (clientSecret) {
134
+ params.set("client_secret", clientSecret);
135
+ }
136
+ let data = null;
137
+ for (let attempt = 0; ; attempt++) {
138
+ if (attempt > 0) {
139
+ await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
114
140
  }
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
- };
141
+ let response;
142
+ try {
143
+ response = await fetch(introspectionEndpoint, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
146
+ body: params,
147
+ signal: AbortSignal.timeout(introspectTimeoutMs)
148
+ });
149
+ } catch {
150
+ if (attempt < introspectRetries) continue;
151
+ return null;
133
152
  }
134
- if (data.active) {
135
- introspectCache.set(token, { active: true, payload });
136
- } else if (introspectNegativeCacheTtl > 0) {
153
+ if (!response.ok) {
154
+ if (response.status >= 500 && attempt < introspectRetries) continue;
155
+ return null;
156
+ }
157
+ data = await response.json();
158
+ break;
159
+ }
160
+ if (!data) return null;
161
+ if (data.active && !matchesAudience(data)) {
162
+ if (introspectNegativeCacheTtl > 0) {
137
163
  introspectCache.set(token, { active: false, payload: null }, {
138
164
  ttl: introspectNegativeCacheTtl
139
165
  });
140
166
  }
141
- return data;
142
- } catch {
143
- return null;
167
+ return { active: false };
168
+ }
169
+ let payload = null;
170
+ if (data.active && data.sub) {
171
+ payload = {
172
+ sub: data.sub,
173
+ aud: audience,
174
+ iss: issuer,
175
+ client_id: data.client_id,
176
+ scope: data.scope,
177
+ exp: data.exp
178
+ };
179
+ }
180
+ if (data.active) {
181
+ introspectCache.set(token, { active: true, payload });
182
+ } else if (introspectNegativeCacheTtl > 0) {
183
+ introspectCache.set(token, { active: false, payload: null }, {
184
+ ttl: introspectNegativeCacheTtl
185
+ });
144
186
  }
187
+ return data;
145
188
  }
146
189
  async function verifyLocally(token) {
147
190
  if (!accessTokenSecret) return null;
@@ -150,7 +193,8 @@ function createTokenVerifier(options) {
150
193
  const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
151
194
  issuer,
152
195
  audience,
153
- algorithms: ["HS256"]
196
+ algorithms: ["HS256"],
197
+ clockTolerance: clockToleranceSeconds
154
198
  });
155
199
  if (payload.type !== "access_token") {
156
200
  return null;
@@ -170,7 +214,8 @@ function createTokenVerifier(options) {
170
214
  const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
171
215
  issuer,
172
216
  audience,
173
- algorithms: ["RS256"]
217
+ algorithms: ["RS256"],
218
+ clockTolerance: clockToleranceSeconds
174
219
  });
175
220
  if (payload.type !== "access_token") return null;
176
221
  return payload;
@@ -180,7 +225,8 @@ function createTokenVerifier(options) {
180
225
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
181
226
  issuer,
182
227
  audience,
183
- algorithms: ["RS256"]
228
+ algorithms: ["RS256"],
229
+ clockTolerance: clockToleranceSeconds
184
230
  });
185
231
  if (payload.type !== "access_token") return null;
186
232
  return payload;
@@ -237,17 +283,22 @@ function createTokenVerifier(options) {
237
283
  * 1. type === "logout_token"
238
284
  * 2. iss 匹配配置的 issuer
239
285
  * 3. aud 包含当前 client_id
240
- * 4. events 包含 backchannel-logout 事件
286
+ * 4. exp 存在(规范要求)
287
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
241
288
  *
242
289
  * @returns LogoutTokenPayload 或 null(验证失败)
243
290
  */
244
291
  async verifyLogoutToken(token) {
245
- const validateLogoutPayload = (payload) => {
292
+ const validateLogoutPayload = async (payload) => {
246
293
  if (payload.type !== "logout_token") {
247
294
  return null;
248
295
  }
296
+ if (typeof payload.exp !== "number") {
297
+ return null;
298
+ }
249
299
  const events = payload.events;
250
- if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
300
+ const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
301
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
251
302
  return null;
252
303
  }
253
304
  const jti = payload.jti;
@@ -256,10 +307,17 @@ function createTokenVerifier(options) {
256
307
  }
257
308
  const iss = payload.iss || issuer;
258
309
  const jtiKey = `${iss}:${jti}`;
259
- if (processedLogoutJtis.has(jtiKey)) {
260
- return null;
310
+ if (logoutJtiStore) {
311
+ if (await logoutJtiStore.has(jtiKey)) {
312
+ return null;
313
+ }
314
+ await logoutJtiStore.add(jtiKey, 10 * 60);
315
+ } else {
316
+ if (processedLogoutJtis.has(jtiKey)) {
317
+ return null;
318
+ }
319
+ processedLogoutJtis.set(jtiKey, Date.now());
261
320
  }
262
- processedLogoutJtis.set(jtiKey, Date.now());
263
321
  return payload;
264
322
  };
265
323
  let alg;
@@ -275,9 +333,10 @@ function createTokenVerifier(options) {
275
333
  const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
276
334
  issuer,
277
335
  audience,
278
- algorithms: ["RS256"]
336
+ algorithms: ["RS256"],
337
+ clockTolerance: clockToleranceSeconds
279
338
  });
280
- return validateLogoutPayload(payload);
339
+ return await validateLogoutPayload(payload);
281
340
  } catch {
282
341
  return null;
283
342
  }
@@ -288,9 +347,10 @@ function createTokenVerifier(options) {
288
347
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
289
348
  issuer,
290
349
  audience,
291
- algorithms: ["RS256"]
350
+ algorithms: ["RS256"],
351
+ clockTolerance: clockToleranceSeconds
292
352
  });
293
- return validateLogoutPayload(payload);
353
+ return await validateLogoutPayload(payload);
294
354
  } catch {
295
355
  return null;
296
356
  }
@@ -305,9 +365,10 @@ function createTokenVerifier(options) {
305
365
  const { payload } = await (0, import_jose.jwtVerify)(token, key, {
306
366
  issuer,
307
367
  audience,
308
- algorithms: ["HS256"]
368
+ algorithms: ["HS256"],
369
+ clockTolerance: clockToleranceSeconds
309
370
  });
310
- return validateLogoutPayload(payload);
371
+ return await validateLogoutPayload(payload);
311
372
  } catch {
312
373
  return null;
313
374
  }
@@ -324,14 +385,18 @@ function ssoMiddleware(options) {
324
385
  if (!token) {
325
386
  if (res.status) {
326
387
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
388
+ return;
327
389
  }
390
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
328
391
  return;
329
392
  }
330
393
  const payload = await verifier.verify(token);
331
394
  if (!payload) {
332
395
  if (res.status) {
333
396
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
397
+ return;
334
398
  }
399
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
335
400
  return;
336
401
  }
337
402
  req.user = payload;
package/dist/index.mjs CHANGED
@@ -30,7 +30,10 @@ 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
34
37
  } = options;
35
38
  const introspectCache = createIntrospectCache(introspectCacheTtl);
36
39
  let _jwksKeySet = null;
@@ -78,6 +81,14 @@ function createTokenVerifier(options) {
78
81
  }
79
82
  return _logoutRs256PublicKey;
80
83
  }
84
+ function matchesAudience(data) {
85
+ const aud = data.aud;
86
+ if (typeof aud === "string") return aud === audience;
87
+ if (Array.isArray(aud)) return aud.includes(audience);
88
+ if (typeof data.client_id === "string") return data.client_id === audience;
89
+ return true;
90
+ }
91
+ const inflightIntrospects = /* @__PURE__ */ new Map();
81
92
  async function introspect(token) {
82
93
  if (!introspectionEndpoint || !clientId) {
83
94
  return null;
@@ -86,41 +97,73 @@ function createTokenVerifier(options) {
86
97
  if (cached) {
87
98
  return { active: cached.active, ...cached.payload || {} };
88
99
  }
89
- try {
90
- const params = new URLSearchParams({ token, client_id: clientId });
91
- if (clientSecret) {
92
- params.set("client_secret", clientSecret);
100
+ const inflight = inflightIntrospects.get(token);
101
+ if (inflight) {
102
+ return inflight;
103
+ }
104
+ const promise = doIntrospect(token).finally(() => {
105
+ inflightIntrospects.delete(token);
106
+ });
107
+ inflightIntrospects.set(token, promise);
108
+ return promise;
109
+ }
110
+ async function doIntrospect(token) {
111
+ const params = new URLSearchParams({ token, client_id: clientId });
112
+ if (clientSecret) {
113
+ params.set("client_secret", clientSecret);
114
+ }
115
+ let data = null;
116
+ for (let attempt = 0; ; attempt++) {
117
+ if (attempt > 0) {
118
+ await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
93
119
  }
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
- };
120
+ let response;
121
+ try {
122
+ response = await fetch(introspectionEndpoint, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
125
+ body: params,
126
+ signal: AbortSignal.timeout(introspectTimeoutMs)
127
+ });
128
+ } catch {
129
+ if (attempt < introspectRetries) continue;
130
+ return null;
112
131
  }
113
- if (data.active) {
114
- introspectCache.set(token, { active: true, payload });
115
- } else if (introspectNegativeCacheTtl > 0) {
132
+ if (!response.ok) {
133
+ if (response.status >= 500 && attempt < introspectRetries) continue;
134
+ return null;
135
+ }
136
+ data = await response.json();
137
+ break;
138
+ }
139
+ if (!data) return null;
140
+ if (data.active && !matchesAudience(data)) {
141
+ if (introspectNegativeCacheTtl > 0) {
116
142
  introspectCache.set(token, { active: false, payload: null }, {
117
143
  ttl: introspectNegativeCacheTtl
118
144
  });
119
145
  }
120
- return data;
121
- } catch {
122
- return null;
146
+ return { active: false };
147
+ }
148
+ let payload = null;
149
+ if (data.active && data.sub) {
150
+ payload = {
151
+ sub: data.sub,
152
+ aud: audience,
153
+ iss: issuer,
154
+ client_id: data.client_id,
155
+ scope: data.scope,
156
+ exp: data.exp
157
+ };
158
+ }
159
+ if (data.active) {
160
+ introspectCache.set(token, { active: true, payload });
161
+ } else if (introspectNegativeCacheTtl > 0) {
162
+ introspectCache.set(token, { active: false, payload: null }, {
163
+ ttl: introspectNegativeCacheTtl
164
+ });
123
165
  }
166
+ return data;
124
167
  }
125
168
  async function verifyLocally(token) {
126
169
  if (!accessTokenSecret) return null;
@@ -129,7 +172,8 @@ function createTokenVerifier(options) {
129
172
  const { payload } = await jwtVerify(token, secret, {
130
173
  issuer,
131
174
  audience,
132
- algorithms: ["HS256"]
175
+ algorithms: ["HS256"],
176
+ clockTolerance: clockToleranceSeconds
133
177
  });
134
178
  if (payload.type !== "access_token") {
135
179
  return null;
@@ -149,7 +193,8 @@ function createTokenVerifier(options) {
149
193
  const { payload } = await jwtVerify(token, directKey, {
150
194
  issuer,
151
195
  audience,
152
- algorithms: ["RS256"]
196
+ algorithms: ["RS256"],
197
+ clockTolerance: clockToleranceSeconds
153
198
  });
154
199
  if (payload.type !== "access_token") return null;
155
200
  return payload;
@@ -159,7 +204,8 @@ function createTokenVerifier(options) {
159
204
  const { payload } = await jwtVerify(token, jwks, {
160
205
  issuer,
161
206
  audience,
162
- algorithms: ["RS256"]
207
+ algorithms: ["RS256"],
208
+ clockTolerance: clockToleranceSeconds
163
209
  });
164
210
  if (payload.type !== "access_token") return null;
165
211
  return payload;
@@ -216,17 +262,22 @@ function createTokenVerifier(options) {
216
262
  * 1. type === "logout_token"
217
263
  * 2. iss 匹配配置的 issuer
218
264
  * 3. aud 包含当前 client_id
219
- * 4. events 包含 backchannel-logout 事件
265
+ * 4. exp 存在(规范要求)
266
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
220
267
  *
221
268
  * @returns LogoutTokenPayload 或 null(验证失败)
222
269
  */
223
270
  async verifyLogoutToken(token) {
224
- const validateLogoutPayload = (payload) => {
271
+ const validateLogoutPayload = async (payload) => {
225
272
  if (payload.type !== "logout_token") {
226
273
  return null;
227
274
  }
275
+ if (typeof payload.exp !== "number") {
276
+ return null;
277
+ }
228
278
  const events = payload.events;
229
- if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
279
+ const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
280
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
230
281
  return null;
231
282
  }
232
283
  const jti = payload.jti;
@@ -235,10 +286,17 @@ function createTokenVerifier(options) {
235
286
  }
236
287
  const iss = payload.iss || issuer;
237
288
  const jtiKey = `${iss}:${jti}`;
238
- if (processedLogoutJtis.has(jtiKey)) {
239
- return null;
289
+ if (logoutJtiStore) {
290
+ if (await logoutJtiStore.has(jtiKey)) {
291
+ return null;
292
+ }
293
+ await logoutJtiStore.add(jtiKey, 10 * 60);
294
+ } else {
295
+ if (processedLogoutJtis.has(jtiKey)) {
296
+ return null;
297
+ }
298
+ processedLogoutJtis.set(jtiKey, Date.now());
240
299
  }
241
- processedLogoutJtis.set(jtiKey, Date.now());
242
300
  return payload;
243
301
  };
244
302
  let alg;
@@ -254,9 +312,10 @@ function createTokenVerifier(options) {
254
312
  const { payload } = await jwtVerify(token, directKey, {
255
313
  issuer,
256
314
  audience,
257
- algorithms: ["RS256"]
315
+ algorithms: ["RS256"],
316
+ clockTolerance: clockToleranceSeconds
258
317
  });
259
- return validateLogoutPayload(payload);
318
+ return await validateLogoutPayload(payload);
260
319
  } catch {
261
320
  return null;
262
321
  }
@@ -267,9 +326,10 @@ function createTokenVerifier(options) {
267
326
  const { payload } = await jwtVerify(token, jwks, {
268
327
  issuer,
269
328
  audience,
270
- algorithms: ["RS256"]
329
+ algorithms: ["RS256"],
330
+ clockTolerance: clockToleranceSeconds
271
331
  });
272
- return validateLogoutPayload(payload);
332
+ return await validateLogoutPayload(payload);
273
333
  } catch {
274
334
  return null;
275
335
  }
@@ -284,9 +344,10 @@ function createTokenVerifier(options) {
284
344
  const { payload } = await jwtVerify(token, key, {
285
345
  issuer,
286
346
  audience,
287
- algorithms: ["HS256"]
347
+ algorithms: ["HS256"],
348
+ clockTolerance: clockToleranceSeconds
288
349
  });
289
- return validateLogoutPayload(payload);
350
+ return await validateLogoutPayload(payload);
290
351
  } catch {
291
352
  return null;
292
353
  }
@@ -303,14 +364,18 @@ function ssoMiddleware(options) {
303
364
  if (!token) {
304
365
  if (res.status) {
305
366
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
367
+ return;
306
368
  }
369
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
307
370
  return;
308
371
  }
309
372
  const payload = await verifier.verify(token);
310
373
  if (!payload) {
311
374
  if (res.status) {
312
375
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
376
+ return;
313
377
  }
378
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
314
379
  return;
315
380
  }
316
381
  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.1",
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",