@nihplod/sso-verify 1.0.3 → 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,6 +86,61 @@ 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
+
93
+ ## Advanced Options
94
+
95
+ | Option | Default | Description |
96
+ | --- | --- | --- |
97
+ | `introspectCacheTtl` | `30000` | Cache TTL (ms) for `active: true` introspection results |
98
+ | `introspectNegativeCacheTtl` | `5000` | Cache TTL (ms) for `active: false` results; set `0` to disable caching of revoked/invalid tokens |
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) |
102
+ | `accessTokenPublicKey` | — | RS256 public key (PEM) for local access token verification |
103
+ | `jwksUri` | — | JWKS endpoint URL; public keys are matched by `kid` |
104
+ | `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
105
+ | `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
106
+ | `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
107
+
108
+ ### Revocation latency trade-off
109
+
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.
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
+
122
+ ### Logout token verification
123
+
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) */
@@ -53,10 +54,22 @@ interface SsoVerifierOptions {
53
54
  accessTokenSecret?: string;
54
55
  /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
55
56
  introspectCacheTtl?: number;
57
+ /**
58
+ * Introspection 请求超时时间(毫秒),默认 10 秒。
59
+ * 超时按验证失败处理(返回 null)。
60
+ */
61
+ introspectTimeoutMs?: number;
62
+ /**
63
+ * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
64
+ * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
65
+ * 设为 0 表示不缓存 active:false 结果。
66
+ */
67
+ introspectNegativeCacheTtl?: number;
56
68
  /**
57
69
  * Access Token RS256 公钥(PEM 格式,可选)。
58
- * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
59
- * 避免每次都调用 Introspection 端点。
70
+ * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
+ * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
+ * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
60
73
  */
61
74
  accessTokenPublicKey?: string;
62
75
  /**
@@ -71,6 +84,43 @@ interface SsoVerifierOptions {
71
84
  * 若未提供,将回退使用 accessTokenSecret 进行验证。
72
85
  */
73
86
  logoutTokenSecret?: string;
87
+ /**
88
+ * Logout Token RS256 公钥(PEM 格式,可选)。
89
+ * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
90
+ * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
91
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
92
+ * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
93
+ * 不会静默回退到 HS256。
94
+ */
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>;
74
124
  }
75
125
  interface VerifiedTokenPayload extends JWTPayload {
76
126
  sub: string;
@@ -114,6 +164,7 @@ interface LogoutTokenPayload {
114
164
  interface IntrospectResponse {
115
165
  active: boolean;
116
166
  sub?: string;
167
+ aud?: string | string[];
117
168
  client_id?: string;
118
169
  scope?: string;
119
170
  exp?: number;
@@ -147,12 +198,27 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
147
198
  * 1. type === "logout_token"
148
199
  * 2. iss 匹配配置的 issuer
149
200
  * 3. aud 包含当前 client_id
150
- * 4. events 包含 backchannel-logout 事件
201
+ * 4. exp 存在(规范要求)
202
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
151
203
  *
152
204
  * @returns LogoutTokenPayload 或 null(验证失败)
153
205
  */
154
206
  verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
155
207
  };
208
+ /** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
209
+ interface SsoMiddlewareRequest {
210
+ headers?: Record<string, string | undefined>;
211
+ /** 验证通过后由中间件挂载的用户信息 */
212
+ user?: VerifiedTokenPayload;
213
+ [key: string]: unknown;
214
+ }
215
+ /** 框架无关的最小中间件响应类型 */
216
+ interface SsoMiddlewareResponse {
217
+ status?: (code: number) => {
218
+ json: (body: unknown) => unknown;
219
+ };
220
+ [key: string]: unknown;
221
+ }
156
222
  /**
157
223
  * Express/Next.js 兼容中间件
158
224
  *
@@ -177,7 +243,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
177
243
  * }
178
244
  * ```
179
245
  */
180
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
246
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
181
247
  /**
182
248
  * 创建 Logout Token 专用验证器
183
249
  *
@@ -214,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
214
280
  verify(token: string): Promise<LogoutTokenPayload | null>;
215
281
  };
216
282
 
217
- export { type LogoutTokenPayload, 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) */
@@ -53,10 +54,22 @@ interface SsoVerifierOptions {
53
54
  accessTokenSecret?: string;
54
55
  /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
55
56
  introspectCacheTtl?: number;
57
+ /**
58
+ * Introspection 请求超时时间(毫秒),默认 10 秒。
59
+ * 超时按验证失败处理(返回 null)。
60
+ */
61
+ introspectTimeoutMs?: number;
62
+ /**
63
+ * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
64
+ * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
65
+ * 设为 0 表示不缓存 active:false 结果。
66
+ */
67
+ introspectNegativeCacheTtl?: number;
56
68
  /**
57
69
  * Access Token RS256 公钥(PEM 格式,可选)。
58
- * 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
59
- * 避免每次都调用 Introspection 端点。
70
+ * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
+ * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
+ * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
60
73
  */
61
74
  accessTokenPublicKey?: string;
62
75
  /**
@@ -71,6 +84,43 @@ interface SsoVerifierOptions {
71
84
  * 若未提供,将回退使用 accessTokenSecret 进行验证。
72
85
  */
73
86
  logoutTokenSecret?: string;
87
+ /**
88
+ * Logout Token RS256 公钥(PEM 格式,可选)。
89
+ * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
90
+ * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
91
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
92
+ * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
93
+ * 不会静默回退到 HS256。
94
+ */
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>;
74
124
  }
75
125
  interface VerifiedTokenPayload extends JWTPayload {
76
126
  sub: string;
@@ -114,6 +164,7 @@ interface LogoutTokenPayload {
114
164
  interface IntrospectResponse {
115
165
  active: boolean;
116
166
  sub?: string;
167
+ aud?: string | string[];
117
168
  client_id?: string;
118
169
  scope?: string;
119
170
  exp?: number;
@@ -147,12 +198,27 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
147
198
  * 1. type === "logout_token"
148
199
  * 2. iss 匹配配置的 issuer
149
200
  * 3. aud 包含当前 client_id
150
- * 4. events 包含 backchannel-logout 事件
201
+ * 4. exp 存在(规范要求)
202
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
151
203
  *
152
204
  * @returns LogoutTokenPayload 或 null(验证失败)
153
205
  */
154
206
  verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
155
207
  };
208
+ /** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
209
+ interface SsoMiddlewareRequest {
210
+ headers?: Record<string, string | undefined>;
211
+ /** 验证通过后由中间件挂载的用户信息 */
212
+ user?: VerifiedTokenPayload;
213
+ [key: string]: unknown;
214
+ }
215
+ /** 框架无关的最小中间件响应类型 */
216
+ interface SsoMiddlewareResponse {
217
+ status?: (code: number) => {
218
+ json: (body: unknown) => unknown;
219
+ };
220
+ [key: string]: unknown;
221
+ }
156
222
  /**
157
223
  * Express/Next.js 兼容中间件
158
224
  *
@@ -177,7 +243,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
177
243
  * }
178
244
  * ```
179
245
  */
180
- declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
246
+ declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
181
247
  /**
182
248
  * 创建 Logout Token 专用验证器
183
249
  *
@@ -214,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
214
280
  verify(token: string): Promise<LogoutTokenPayload | null>;
215
281
  };
216
282
 
217
- export { type LogoutTokenPayload, 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
@@ -48,7 +48,13 @@ function createTokenVerifier(options) {
48
48
  accessTokenPublicKey,
49
49
  jwksUri,
50
50
  logoutTokenSecret,
51
- introspectCacheTtl = 30 * 1e3
51
+ logoutTokenPublicKey,
52
+ introspectCacheTtl = 30 * 1e3,
53
+ introspectTimeoutMs = 10 * 1e3,
54
+ introspectNegativeCacheTtl = 5 * 1e3,
55
+ introspectRetries = 1,
56
+ clockToleranceSeconds = 60,
57
+ logoutJtiStore
52
58
  } = options;
53
59
  const introspectCache = createIntrospectCache(introspectCacheTtl);
54
60
  let _jwksKeySet = null;
@@ -79,6 +85,31 @@ function createTokenVerifier(options) {
79
85
  }
80
86
  return _rs256PublicKey;
81
87
  }
88
+ let _logoutRs256PublicKey = null;
89
+ let _logoutRs256KeyInitialized = false;
90
+ async function getLogoutRS256PublicKey() {
91
+ if (!_logoutRs256KeyInitialized) {
92
+ _logoutRs256KeyInitialized = true;
93
+ if (!logoutTokenPublicKey) {
94
+ _logoutRs256PublicKey = null;
95
+ } else {
96
+ try {
97
+ _logoutRs256PublicKey = await (0, import_jose.importSPKI)(logoutTokenPublicKey, "RS256");
98
+ } catch {
99
+ _logoutRs256PublicKey = null;
100
+ }
101
+ }
102
+ }
103
+ return _logoutRs256PublicKey;
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();
82
113
  async function introspect(token) {
83
114
  if (!introspectionEndpoint || !clientId) {
84
115
  return null;
@@ -87,34 +118,73 @@ function createTokenVerifier(options) {
87
118
  if (cached) {
88
119
  return { active: cached.active, ...cached.payload || {} };
89
120
  }
90
- try {
91
- const params = new URLSearchParams({ token, client_id: clientId });
92
- if (clientSecret) {
93
- 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));
94
140
  }
95
- const response = await fetch(introspectionEndpoint, {
96
- method: "POST",
97
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
98
- body: params
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
- };
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;
112
152
  }
113
- introspectCache.set(token, { active: data.active, payload });
114
- return data;
115
- } catch {
116
- return null;
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) {
163
+ introspectCache.set(token, { active: false, payload: null }, {
164
+ ttl: introspectNegativeCacheTtl
165
+ });
166
+ }
167
+ return { active: false };
117
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
+ });
186
+ }
187
+ return data;
118
188
  }
119
189
  async function verifyLocally(token) {
120
190
  if (!accessTokenSecret) return null;
@@ -122,7 +192,9 @@ function createTokenVerifier(options) {
122
192
  const secret = new TextEncoder().encode(accessTokenSecret);
123
193
  const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
124
194
  issuer,
125
- audience
195
+ audience,
196
+ algorithms: ["HS256"],
197
+ clockTolerance: clockToleranceSeconds
126
198
  });
127
199
  if (payload.type !== "access_token") {
128
200
  return null;
@@ -142,7 +214,8 @@ function createTokenVerifier(options) {
142
214
  const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
143
215
  issuer,
144
216
  audience,
145
- algorithms: ["RS256"]
217
+ algorithms: ["RS256"],
218
+ clockTolerance: clockToleranceSeconds
146
219
  });
147
220
  if (payload.type !== "access_token") return null;
148
221
  return payload;
@@ -152,7 +225,8 @@ function createTokenVerifier(options) {
152
225
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
153
226
  issuer,
154
227
  audience,
155
- algorithms: ["RS256"]
228
+ algorithms: ["RS256"],
229
+ clockTolerance: clockToleranceSeconds
156
230
  });
157
231
  if (payload.type !== "access_token") return null;
158
232
  return payload;
@@ -181,9 +255,9 @@ function createTokenVerifier(options) {
181
255
  const rs256Result = await verifyWithRS256(token);
182
256
  if (rs256Result) return rs256Result;
183
257
  const result = await introspect(token);
184
- if (!result?.active) return null;
258
+ if (!result?.active || !result.sub) return null;
185
259
  return {
186
- sub: result.sub || "",
260
+ sub: result.sub,
187
261
  aud: audience,
188
262
  iss: issuer,
189
263
  client_id: result.client_id,
@@ -209,39 +283,97 @@ function createTokenVerifier(options) {
209
283
  * 1. type === "logout_token"
210
284
  * 2. iss 匹配配置的 issuer
211
285
  * 3. aud 包含当前 client_id
212
- * 4. events 包含 backchannel-logout 事件
286
+ * 4. exp 存在(规范要求)
287
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
213
288
  *
214
289
  * @returns LogoutTokenPayload 或 null(验证失败)
215
290
  */
216
291
  async verifyLogoutToken(token) {
217
- const secret = logoutTokenSecret || accessTokenSecret;
218
- if (!secret) return null;
219
- try {
220
- const key = new TextEncoder().encode(secret);
221
- const { payload } = await (0, import_jose.jwtVerify)(token, key, {
222
- issuer,
223
- audience,
224
- algorithms: ["HS256"]
225
- });
292
+ const validateLogoutPayload = async (payload) => {
226
293
  if (payload.type !== "logout_token") {
227
294
  return null;
228
295
  }
296
+ if (typeof payload.exp !== "number") {
297
+ return null;
298
+ }
229
299
  const events = payload.events;
230
- 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)) {
231
302
  return null;
232
303
  }
233
304
  const jti = payload.jti;
234
305
  if (!jti || typeof jti !== "string") {
235
306
  return null;
236
307
  }
237
- if (processedLogoutJtis.has(jti)) {
238
- return null;
308
+ const iss = payload.iss || issuer;
309
+ const jtiKey = `${iss}:${jti}`;
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());
239
320
  }
240
- processedLogoutJtis.set(jti, Date.now());
241
321
  return payload;
322
+ };
323
+ let alg;
324
+ try {
325
+ alg = (0, import_jose.decodeProtectedHeader)(token).alg;
242
326
  } catch {
243
327
  return null;
244
328
  }
329
+ if (alg === "RS256") {
330
+ const directKey = await getLogoutRS256PublicKey();
331
+ if (directKey) {
332
+ try {
333
+ const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
334
+ issuer,
335
+ audience,
336
+ algorithms: ["RS256"],
337
+ clockTolerance: clockToleranceSeconds
338
+ });
339
+ return await validateLogoutPayload(payload);
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+ const jwks = getJwksKeySet();
345
+ if (jwks) {
346
+ try {
347
+ const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
348
+ issuer,
349
+ audience,
350
+ algorithms: ["RS256"],
351
+ clockTolerance: clockToleranceSeconds
352
+ });
353
+ return await validateLogoutPayload(payload);
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+ if (alg === "HS256") {
361
+ const secret = logoutTokenSecret || accessTokenSecret;
362
+ if (!secret) return null;
363
+ try {
364
+ const key = new TextEncoder().encode(secret);
365
+ const { payload } = await (0, import_jose.jwtVerify)(token, key, {
366
+ issuer,
367
+ audience,
368
+ algorithms: ["HS256"],
369
+ clockTolerance: clockToleranceSeconds
370
+ });
371
+ return await validateLogoutPayload(payload);
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+ return null;
245
377
  }
246
378
  };
247
379
  }
@@ -253,14 +385,18 @@ function ssoMiddleware(options) {
253
385
  if (!token) {
254
386
  if (res.status) {
255
387
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
388
+ return;
256
389
  }
390
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
257
391
  return;
258
392
  }
259
393
  const payload = await verifier.verify(token);
260
394
  if (!payload) {
261
395
  if (res.status) {
262
396
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
397
+ return;
263
398
  }
399
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
264
400
  return;
265
401
  }
266
402
  req.user = payload;
package/dist/index.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  // src/index.ts
2
- import { jwtVerify, importSPKI, createRemoteJWKSet } from "jose";
2
+ import {
3
+ jwtVerify,
4
+ importSPKI,
5
+ createRemoteJWKSet,
6
+ decodeProtectedHeader
7
+ } from "jose";
3
8
  import { LRUCache } from "lru-cache";
4
9
  function createIntrospectCache(ttlMs) {
5
10
  return new LRUCache({
@@ -22,7 +27,13 @@ function createTokenVerifier(options) {
22
27
  accessTokenPublicKey,
23
28
  jwksUri,
24
29
  logoutTokenSecret,
25
- introspectCacheTtl = 30 * 1e3
30
+ logoutTokenPublicKey,
31
+ introspectCacheTtl = 30 * 1e3,
32
+ introspectTimeoutMs = 10 * 1e3,
33
+ introspectNegativeCacheTtl = 5 * 1e3,
34
+ introspectRetries = 1,
35
+ clockToleranceSeconds = 60,
36
+ logoutJtiStore
26
37
  } = options;
27
38
  const introspectCache = createIntrospectCache(introspectCacheTtl);
28
39
  let _jwksKeySet = null;
@@ -53,6 +64,31 @@ function createTokenVerifier(options) {
53
64
  }
54
65
  return _rs256PublicKey;
55
66
  }
67
+ let _logoutRs256PublicKey = null;
68
+ let _logoutRs256KeyInitialized = false;
69
+ async function getLogoutRS256PublicKey() {
70
+ if (!_logoutRs256KeyInitialized) {
71
+ _logoutRs256KeyInitialized = true;
72
+ if (!logoutTokenPublicKey) {
73
+ _logoutRs256PublicKey = null;
74
+ } else {
75
+ try {
76
+ _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, "RS256");
77
+ } catch {
78
+ _logoutRs256PublicKey = null;
79
+ }
80
+ }
81
+ }
82
+ return _logoutRs256PublicKey;
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();
56
92
  async function introspect(token) {
57
93
  if (!introspectionEndpoint || !clientId) {
58
94
  return null;
@@ -61,34 +97,73 @@ function createTokenVerifier(options) {
61
97
  if (cached) {
62
98
  return { active: cached.active, ...cached.payload || {} };
63
99
  }
64
- try {
65
- const params = new URLSearchParams({ token, client_id: clientId });
66
- if (clientSecret) {
67
- 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));
68
119
  }
69
- const response = await fetch(introspectionEndpoint, {
70
- method: "POST",
71
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
72
- body: params
73
- });
74
- if (!response.ok) return null;
75
- const data = await response.json();
76
- let payload = null;
77
- if (data.active && data.sub) {
78
- payload = {
79
- sub: data.sub,
80
- aud: audience,
81
- iss: issuer,
82
- client_id: data.client_id,
83
- scope: data.scope,
84
- exp: data.exp
85
- };
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;
86
131
  }
87
- introspectCache.set(token, { active: data.active, payload });
88
- return data;
89
- } catch {
90
- return null;
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) {
142
+ introspectCache.set(token, { active: false, payload: null }, {
143
+ ttl: introspectNegativeCacheTtl
144
+ });
145
+ }
146
+ return { active: false };
91
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
+ });
165
+ }
166
+ return data;
92
167
  }
93
168
  async function verifyLocally(token) {
94
169
  if (!accessTokenSecret) return null;
@@ -96,7 +171,9 @@ function createTokenVerifier(options) {
96
171
  const secret = new TextEncoder().encode(accessTokenSecret);
97
172
  const { payload } = await jwtVerify(token, secret, {
98
173
  issuer,
99
- audience
174
+ audience,
175
+ algorithms: ["HS256"],
176
+ clockTolerance: clockToleranceSeconds
100
177
  });
101
178
  if (payload.type !== "access_token") {
102
179
  return null;
@@ -116,7 +193,8 @@ function createTokenVerifier(options) {
116
193
  const { payload } = await jwtVerify(token, directKey, {
117
194
  issuer,
118
195
  audience,
119
- algorithms: ["RS256"]
196
+ algorithms: ["RS256"],
197
+ clockTolerance: clockToleranceSeconds
120
198
  });
121
199
  if (payload.type !== "access_token") return null;
122
200
  return payload;
@@ -126,7 +204,8 @@ function createTokenVerifier(options) {
126
204
  const { payload } = await jwtVerify(token, jwks, {
127
205
  issuer,
128
206
  audience,
129
- algorithms: ["RS256"]
207
+ algorithms: ["RS256"],
208
+ clockTolerance: clockToleranceSeconds
130
209
  });
131
210
  if (payload.type !== "access_token") return null;
132
211
  return payload;
@@ -155,9 +234,9 @@ function createTokenVerifier(options) {
155
234
  const rs256Result = await verifyWithRS256(token);
156
235
  if (rs256Result) return rs256Result;
157
236
  const result = await introspect(token);
158
- if (!result?.active) return null;
237
+ if (!result?.active || !result.sub) return null;
159
238
  return {
160
- sub: result.sub || "",
239
+ sub: result.sub,
161
240
  aud: audience,
162
241
  iss: issuer,
163
242
  client_id: result.client_id,
@@ -183,39 +262,97 @@ function createTokenVerifier(options) {
183
262
  * 1. type === "logout_token"
184
263
  * 2. iss 匹配配置的 issuer
185
264
  * 3. aud 包含当前 client_id
186
- * 4. events 包含 backchannel-logout 事件
265
+ * 4. exp 存在(规范要求)
266
+ * 5. events 包含 backchannel-logout 事件,且事件值为对象
187
267
  *
188
268
  * @returns LogoutTokenPayload 或 null(验证失败)
189
269
  */
190
270
  async verifyLogoutToken(token) {
191
- const secret = logoutTokenSecret || accessTokenSecret;
192
- if (!secret) return null;
193
- try {
194
- const key = new TextEncoder().encode(secret);
195
- const { payload } = await jwtVerify(token, key, {
196
- issuer,
197
- audience,
198
- algorithms: ["HS256"]
199
- });
271
+ const validateLogoutPayload = async (payload) => {
200
272
  if (payload.type !== "logout_token") {
201
273
  return null;
202
274
  }
275
+ if (typeof payload.exp !== "number") {
276
+ return null;
277
+ }
203
278
  const events = payload.events;
204
- 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)) {
205
281
  return null;
206
282
  }
207
283
  const jti = payload.jti;
208
284
  if (!jti || typeof jti !== "string") {
209
285
  return null;
210
286
  }
211
- if (processedLogoutJtis.has(jti)) {
212
- return null;
287
+ const iss = payload.iss || issuer;
288
+ const jtiKey = `${iss}:${jti}`;
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());
213
299
  }
214
- processedLogoutJtis.set(jti, Date.now());
215
300
  return payload;
301
+ };
302
+ let alg;
303
+ try {
304
+ alg = decodeProtectedHeader(token).alg;
216
305
  } catch {
217
306
  return null;
218
307
  }
308
+ if (alg === "RS256") {
309
+ const directKey = await getLogoutRS256PublicKey();
310
+ if (directKey) {
311
+ try {
312
+ const { payload } = await jwtVerify(token, directKey, {
313
+ issuer,
314
+ audience,
315
+ algorithms: ["RS256"],
316
+ clockTolerance: clockToleranceSeconds
317
+ });
318
+ return await validateLogoutPayload(payload);
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+ const jwks = getJwksKeySet();
324
+ if (jwks) {
325
+ try {
326
+ const { payload } = await jwtVerify(token, jwks, {
327
+ issuer,
328
+ audience,
329
+ algorithms: ["RS256"],
330
+ clockTolerance: clockToleranceSeconds
331
+ });
332
+ return await validateLogoutPayload(payload);
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+ if (alg === "HS256") {
340
+ const secret = logoutTokenSecret || accessTokenSecret;
341
+ if (!secret) return null;
342
+ try {
343
+ const key = new TextEncoder().encode(secret);
344
+ const { payload } = await jwtVerify(token, key, {
345
+ issuer,
346
+ audience,
347
+ algorithms: ["HS256"],
348
+ clockTolerance: clockToleranceSeconds
349
+ });
350
+ return await validateLogoutPayload(payload);
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+ return null;
219
356
  }
220
357
  };
221
358
  }
@@ -227,14 +364,18 @@ function ssoMiddleware(options) {
227
364
  if (!token) {
228
365
  if (res.status) {
229
366
  res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
367
+ return;
230
368
  }
369
+ next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
231
370
  return;
232
371
  }
233
372
  const payload = await verifier.verify(token);
234
373
  if (!payload) {
235
374
  if (res.status) {
236
375
  res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
376
+ return;
237
377
  }
378
+ next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
238
379
  return;
239
380
  }
240
381
  req.user = payload;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.0.3",
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",