@nihplod/sso-verify 1.3.0 → 2.0.0

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
@@ -1,157 +1,157 @@
1
- # @nihplod/sso-verify
2
-
3
- NIHPLOD SSO token verification toolkit — for sub-projects to validate access tokens issued by the main site backend.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @nihplod/sso-verify
9
- ```
10
-
11
- ## Usage
12
-
13
- ### Method 1: Introspection (Recommended)
14
-
15
- Suitable for all sub-projects; no signing keys required.
16
-
17
- #### Confidential Client (server-side or BFF)
18
-
19
- ```typescript
20
- import { createTokenVerifier } from "@nihplod/sso-verify";
21
-
22
- const verifier = createTokenVerifier({
23
- introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
24
- clientId: "your-client-id",
25
- clientSecret: "your-client-secret",
26
- audience: "your-client-id",
27
- issuer: "https://nihplod.cn",
28
- });
29
-
30
- const payload = await verifier.verify(token);
31
- if (payload) {
32
- console.log(payload.sub); // User ID
33
- }
34
- ```
35
-
36
- #### Public Client (SPA / mobile / desktop)
37
-
38
- Public Clients have no `client_secret`. The introspection endpoint still accepts verification with `client_id` only.
39
-
40
- ```typescript
41
- import { createTokenVerifier } from "@nihplod/sso-verify";
42
-
43
- const verifier = createTokenVerifier({
44
- introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
45
- clientId: "your-public-client-id",
46
- audience: "your-public-client-id",
47
- issuer: "https://nihplod.cn",
48
- });
49
-
50
- const payload = await verifier.verify(token);
51
- ```
52
-
53
- ### Method 2: Local JWT Verification (Internal Confidential Clients Only)
54
-
55
- Only use this when the sub-project shares `JWT_ACCESS_SECRET` with the main site.
56
-
57
- ```typescript
58
- import { createTokenVerifier } from "@nihplod/sso-verify";
59
-
60
- const verifier = createTokenVerifier({
61
- accessTokenSecret: process.env.JWT_ACCESS_SECRET,
62
- audience: "your-client-id",
63
- issuer: "https://nihplod.cn",
64
- });
65
-
66
- const payload = await verifier.verify(token);
67
- ```
68
-
69
- ## Express / Connect Middleware
70
-
71
- ```typescript
72
- import { ssoMiddleware } from "@nihplod/sso-verify";
73
-
74
- app.use(
75
- "/api/protected",
76
- ssoMiddleware({
77
- introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
78
- clientId: "your-client-id",
79
- clientSecret: "your-client-secret",
80
- audience: "your-client-id",
81
- })
82
- );
83
- ```
84
-
85
- After successful verification, the token payload is attached to `req.user`.
86
-
87
- ## Notes
88
-
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
- | `allowLegacyAccessTokenSecretFallback` | `false` | Allow HS256 `logout_token`s to fall back to `accessTokenSecret` when `logoutTokenSecret` is unset (deprecated; see "Logout token verification") |
107
- | `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
108
- | `strictAudience` | `true` | Reject introspection responses that carry neither `aud` nor `client_id` (fail-closed). Set `false` only for legacy third-party endpoints that return no audience fields |
109
-
110
- ### Revocation latency trade-off
111
-
112
- 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, additionally capped at the token's remaining `exp`); `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.
113
-
114
- 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`.
115
-
116
- ### Audience (aud) binding for introspection
117
-
118
- When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
119
-
120
- - If the response contains `aud` (string or array), it must include the configured `audience`.
121
- - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
122
- - If the endpoint returns neither field, the response is rejected by default (`strictAudience: true`, fail-closed). The main site's introspection endpoint always returns `client_id`, so tokens issued to other clients are always rejected. Set `strictAudience: false` only when integrating a legacy third-party endpoint that returns no audience fields (not recommended).
123
-
124
- > ⚠️ **Fail-closed by default.** A response missing both `aud` and `client_id` is rejected instead of trusted, eliminating the confused-deputy fail-open path. Only lower this when you fully trust the introspection endpoint and cannot change it.
125
-
126
- ### Logout token verification
127
-
128
- 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` is explicitly configured.
129
-
130
- > ⚠️ **Breaking change (since the version introducing `allowLegacyAccessTokenSecretFallback`):** the legacy behavior of falling back to `accessTokenSecret` for HS256 `logout_token`s is now **disabled by default**. Any service that legitimately shares `JWT_ACCESS_SECRET` could otherwise mint logout tokens for arbitrary `sub`/`sid` (mass forced logout), and an access-secret leak would compromise both trust boundaries. If you cannot migrate immediately, set `allowLegacyAccessTokenSecretFallback: true` (a one-time deprecation warning is logged), and plan to move to `logoutTokenSecret` or the RS256 public key.
131
-
132
- 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.
133
-
134
- 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.
135
-
136
- ### Multi-instance deployments (logout jti replay store)
137
-
138
- 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`:
139
-
140
- ```typescript
141
- const verifier = createTokenVerifier({
142
- audience: "your-client-id",
143
- logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
144
- logoutJtiStore: {
145
- // Both sync and async implementations are supported.
146
- // addIfAbsent is strongly recommended: it must atomically SET NX EX and
147
- // return true only when this call created the key (first use).
148
- addIfAbsent: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds, "NX").then((r) => r === "OK"),
149
- // has/add are the legacy fallback used only when addIfAbsent is not provided
150
- // (non-atomic check-then-act; single-instance use only).
151
- has: (key) => redis.exists(key).then((n) => n > 0),
152
- add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
153
- },
154
- });
155
- ```
156
-
157
- `addIfAbsent(key, ttlSeconds)` atomically records the jti and returns whether this call was the first use (implement with Redis `SET key 1 EX ttl NX`). It prevents the TOCTOU race between `has` and `add` across instances. If it is not implemented, the verifier falls back to `has` + `add` (the verifier passes 600 seconds, matching the built-in cache) �?acceptable only for single-instance deployments.
1
+ # @nihplod/sso-verify
2
+
3
+ NIHPLOD SSO token verification toolkit — for sub-projects to validate access tokens issued by the main site backend.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @nihplod/sso-verify
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Method 1: Introspection (Recommended)
14
+
15
+ Suitable for all sub-projects; no signing keys required.
16
+
17
+ #### Confidential Client (server-side or BFF)
18
+
19
+ ```typescript
20
+ import { createTokenVerifier } from "@nihplod/sso-verify";
21
+
22
+ const verifier = createTokenVerifier({
23
+ introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
24
+ clientId: "your-client-id",
25
+ clientSecret: "your-client-secret",
26
+ audience: "your-client-id",
27
+ issuer: "https://nihplod.cn",
28
+ });
29
+
30
+ const payload = await verifier.verify(token);
31
+ if (payload) {
32
+ console.log(payload.sub); // User ID
33
+ }
34
+ ```
35
+
36
+ #### Public Client (SPA / mobile / desktop)
37
+
38
+ Public Clients have no `client_secret`. The introspection endpoint still accepts verification with `client_id` only.
39
+
40
+ ```typescript
41
+ import { createTokenVerifier } from "@nihplod/sso-verify";
42
+
43
+ const verifier = createTokenVerifier({
44
+ introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
45
+ clientId: "your-public-client-id",
46
+ audience: "your-public-client-id",
47
+ issuer: "https://nihplod.cn",
48
+ });
49
+
50
+ const payload = await verifier.verify(token);
51
+ ```
52
+
53
+ ### Method 2: Local JWT Verification (Internal Confidential Clients Only)
54
+
55
+ Only use this when the sub-project shares `JWT_ACCESS_SECRET` with the main site.
56
+
57
+ ```typescript
58
+ import { createTokenVerifier } from "@nihplod/sso-verify";
59
+
60
+ const verifier = createTokenVerifier({
61
+ accessTokenSecret: process.env.JWT_ACCESS_SECRET,
62
+ audience: "your-client-id",
63
+ issuer: "https://nihplod.cn",
64
+ });
65
+
66
+ const payload = await verifier.verify(token);
67
+ ```
68
+
69
+ ## Express / Connect Middleware
70
+
71
+ ```typescript
72
+ import { ssoMiddleware } from "@nihplod/sso-verify";
73
+
74
+ app.use(
75
+ "/api/protected",
76
+ ssoMiddleware({
77
+ introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
78
+ clientId: "your-client-id",
79
+ clientSecret: "your-client-secret",
80
+ audience: "your-client-id",
81
+ })
82
+ );
83
+ ```
84
+
85
+ After successful verification, the token payload is attached to `req.user`.
86
+
87
+ ## Notes
88
+
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
+ | `allowLegacyAccessTokenSecretFallback` | `false` | Allow HS256 `logout_token`s to fall back to `accessTokenSecret` when `logoutTokenSecret` is unset (deprecated; see "Logout token verification") |
107
+ | `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
108
+ | `strictAudience` | `true` | Reject introspection responses that carry neither `aud` nor `client_id` (fail-closed). Set `false` only for legacy third-party endpoints that return no audience fields |
109
+
110
+ ### Revocation latency trade-off
111
+
112
+ 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, additionally capped at the token's remaining `exp`); `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.
113
+
114
+ 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`.
115
+
116
+ ### Audience (aud) binding for introspection
117
+
118
+ When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
119
+
120
+ - If the response contains `aud` (string or array), it must include the configured `audience`.
121
+ - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
122
+ - If the endpoint returns neither field, the response is rejected by default (`strictAudience: true`, fail-closed). The main site's introspection endpoint always returns `client_id`, so tokens issued to other clients are always rejected. Set `strictAudience: false` only when integrating a legacy third-party endpoint that returns no audience fields (not recommended).
123
+
124
+ > ⚠️ **Fail-closed by default.** A response missing both `aud` and `client_id` is rejected instead of trusted, eliminating the confused-deputy fail-open path. Only lower this when you fully trust the introspection endpoint and cannot change it.
125
+
126
+ ### Logout token verification
127
+
128
+ 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` is explicitly configured.
129
+
130
+ > ⚠️ **Breaking change (since the version introducing `allowLegacyAccessTokenSecretFallback`):** the legacy behavior of falling back to `accessTokenSecret` for HS256 `logout_token`s is now **disabled by default**. Any service that legitimately shares `JWT_ACCESS_SECRET` could otherwise mint logout tokens for arbitrary `sub`/`sid` (mass forced logout), and an access-secret leak would compromise both trust boundaries. If you cannot migrate immediately, set `allowLegacyAccessTokenSecretFallback: true` (a one-time deprecation warning is logged), and plan to move to `logoutTokenSecret` or the RS256 public key.
131
+
132
+ 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.
133
+
134
+ 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.
135
+
136
+ ### Multi-instance deployments (logout jti replay store)
137
+
138
+ 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`:
139
+
140
+ ```typescript
141
+ const verifier = createTokenVerifier({
142
+ audience: "your-client-id",
143
+ logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
144
+ logoutJtiStore: {
145
+ // Both sync and async implementations are supported.
146
+ // addIfAbsent is strongly recommended: it must atomically SET NX EX and
147
+ // return true only when this call created the key (first use).
148
+ addIfAbsent: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds, "NX").then((r) => r === "OK"),
149
+ // has/add are the legacy fallback used only when addIfAbsent is not provided
150
+ // (non-atomic check-then-act; single-instance use only).
151
+ has: (key) => redis.exists(key).then((n) => n > 0),
152
+ add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
153
+ },
154
+ });
155
+ ```
156
+
157
+ `addIfAbsent(key, ttlSeconds)` atomically records the jti and returns whether this call was the first use (implement with Redis `SET key 1 EX ttl NX`). It prevents the TOCTOU race between `has` and `add` across instances. If it is not implemented, the verifier falls back to `has` + `add` (the verifier passes 600 seconds, matching the built-in cache) �?acceptable only for single-instance deployments.
package/dist/index.d.mts CHANGED
@@ -115,7 +115,7 @@ interface SsoVerifierOptions {
115
115
  logoutTokenPublicKey?: string;
116
116
  /**
117
117
  * Introspection 请求失败重试次数,默认 1。
118
- * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
118
+ * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。
119
119
  * 设为 0 表示不重试。
120
120
  */
121
121
  introspectRetries?: number;
@@ -140,6 +140,23 @@ interface SsoVerifierOptions {
140
140
  * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
141
141
  */
142
142
  strictAudience?: boolean;
143
+ /**
144
+ * 验证模式(默认 "auto"):
145
+ * - "auto":配置了 introspectionEndpoint + clientId 时使用 Introspection(可实时感知撤销);
146
+ * 未配置时回退本地 JWT 验签。
147
+ * - "introspection":强制 Introspection;未配置端点时回退本地验签。
148
+ * - "local":仅本地 HS256/RS256 验签(不感知撤销,仅建议非敏感、高吞吐场景)。
149
+ * - "hybrid":先本地验签,失败后再 Introspection(旧版行为,性能优先)。
150
+ *
151
+ * 涉及账户状态/授权撤销的鉴权建议使用 "auto" 或 "introspection"。
152
+ */
153
+ verificationMode?: "auto" | "introspection" | "local" | "hybrid";
154
+ /**
155
+ * 是否允许 M2M(client_credentials)token 通过用户态校验(默认 false)。
156
+ * M2M token 的 sub 为 `client:<client_id>`,不是用户 ID;若业务确实需要
157
+ * 校验 M2M token,请显式设为 true 并自行区分 client_type。
158
+ */
159
+ allowM2mTokens?: boolean;
143
160
  }
144
161
  /**
145
162
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -162,6 +179,18 @@ interface VerifiedTokenPayload extends JWTPayload {
162
179
  client_id?: string;
163
180
  scope?: string;
164
181
  phone?: string;
182
+ /** 身份类型:m2m = client_credentials,user = 用户授权码流程 */
183
+ client_type?: "m2m" | "user";
184
+ /** OAuthSession ID;携带 sid 的 token 可被会话级撤销 */
185
+ sid?: string;
186
+ /** 原始认证时间(Unix 秒),供 max_age 判断 */
187
+ auth_time?: number;
188
+ /** DPoP 绑定(RFC 9449 cnf.jkt) */
189
+ cnf?: {
190
+ jkt?: string;
191
+ };
192
+ /** introspection 返回的 token 类型(Bearer / DPoP) */
193
+ token_type?: "Bearer" | "DPoP";
165
194
  }
166
195
  /**
167
196
  * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
@@ -201,20 +230,28 @@ interface IntrospectResponse {
201
230
  client_id?: string;
202
231
  scope?: string;
203
232
  exp?: number;
233
+ token_type?: string;
234
+ client_type?: "m2m" | "user";
235
+ sid?: string;
236
+ auth_time?: number;
237
+ cnf?: {
238
+ jkt?: string;
239
+ };
204
240
  [key: string]: unknown;
205
241
  }
206
242
  declare function createTokenVerifier(options: SsoVerifierOptions): {
207
243
  /**
208
244
  * 验证 Access Token
209
245
  *
210
- * 验证顺序:
211
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
212
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
213
- * 3. 若上述均失败/未配置,调用 Introspection 端点
246
+ * 模式由 verificationMode 控制(默认 "auto"):
247
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
248
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
249
+ * - local:仅本地验签(不感知撤销)。
250
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
214
251
  *
215
252
  * @returns token payload 或者 null(验证失败)
216
253
  */
217
- verify(token: string): Promise<VerifiedTokenPayload | null>;
254
+ verify(rawToken: string): Promise<VerifiedTokenPayload | null>;
218
255
  /**
219
256
  * 直接调用 Introspection 端点
220
257
  */
package/dist/index.d.ts CHANGED
@@ -115,7 +115,7 @@ interface SsoVerifierOptions {
115
115
  logoutTokenPublicKey?: string;
116
116
  /**
117
117
  * Introspection 请求失败重试次数,默认 1。
118
- * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
118
+ * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。
119
119
  * 设为 0 表示不重试。
120
120
  */
121
121
  introspectRetries?: number;
@@ -140,6 +140,23 @@ interface SsoVerifierOptions {
140
140
  * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
141
141
  */
142
142
  strictAudience?: boolean;
143
+ /**
144
+ * 验证模式(默认 "auto"):
145
+ * - "auto":配置了 introspectionEndpoint + clientId 时使用 Introspection(可实时感知撤销);
146
+ * 未配置时回退本地 JWT 验签。
147
+ * - "introspection":强制 Introspection;未配置端点时回退本地验签。
148
+ * - "local":仅本地 HS256/RS256 验签(不感知撤销,仅建议非敏感、高吞吐场景)。
149
+ * - "hybrid":先本地验签,失败后再 Introspection(旧版行为,性能优先)。
150
+ *
151
+ * 涉及账户状态/授权撤销的鉴权建议使用 "auto" 或 "introspection"。
152
+ */
153
+ verificationMode?: "auto" | "introspection" | "local" | "hybrid";
154
+ /**
155
+ * 是否允许 M2M(client_credentials)token 通过用户态校验(默认 false)。
156
+ * M2M token 的 sub 为 `client:<client_id>`,不是用户 ID;若业务确实需要
157
+ * 校验 M2M token,请显式设为 true 并自行区分 client_type。
158
+ */
159
+ allowM2mTokens?: boolean;
143
160
  }
144
161
  /**
145
162
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -162,6 +179,18 @@ interface VerifiedTokenPayload extends JWTPayload {
162
179
  client_id?: string;
163
180
  scope?: string;
164
181
  phone?: string;
182
+ /** 身份类型:m2m = client_credentials,user = 用户授权码流程 */
183
+ client_type?: "m2m" | "user";
184
+ /** OAuthSession ID;携带 sid 的 token 可被会话级撤销 */
185
+ sid?: string;
186
+ /** 原始认证时间(Unix 秒),供 max_age 判断 */
187
+ auth_time?: number;
188
+ /** DPoP 绑定(RFC 9449 cnf.jkt) */
189
+ cnf?: {
190
+ jkt?: string;
191
+ };
192
+ /** introspection 返回的 token 类型(Bearer / DPoP) */
193
+ token_type?: "Bearer" | "DPoP";
165
194
  }
166
195
  /**
167
196
  * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
@@ -201,20 +230,28 @@ interface IntrospectResponse {
201
230
  client_id?: string;
202
231
  scope?: string;
203
232
  exp?: number;
233
+ token_type?: string;
234
+ client_type?: "m2m" | "user";
235
+ sid?: string;
236
+ auth_time?: number;
237
+ cnf?: {
238
+ jkt?: string;
239
+ };
204
240
  [key: string]: unknown;
205
241
  }
206
242
  declare function createTokenVerifier(options: SsoVerifierOptions): {
207
243
  /**
208
244
  * 验证 Access Token
209
245
  *
210
- * 验证顺序:
211
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
212
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
213
- * 3. 若上述均失败/未配置,调用 Introspection 端点
246
+ * 模式由 verificationMode 控制(默认 "auto"):
247
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
248
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
249
+ * - local:仅本地验签(不感知撤销)。
250
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
214
251
  *
215
252
  * @returns token payload 或者 null(验证失败)
216
253
  */
217
- verify(token: string): Promise<VerifiedTokenPayload | null>;
254
+ verify(rawToken: string): Promise<VerifiedTokenPayload | null>;
218
255
  /**
219
256
  * 直接调用 Introspection 端点
220
257
  */
package/dist/index.js CHANGED
@@ -58,7 +58,9 @@ function createTokenVerifier(options) {
58
58
  introspectRetries = 1,
59
59
  clockToleranceSeconds = 60,
60
60
  logoutJtiStore,
61
- strictAudience = true
61
+ strictAudience = true,
62
+ verificationMode = "auto",
63
+ allowM2mTokens = false
62
64
  } = options;
63
65
  let warnedLegacyLogoutSecret = false;
64
66
  if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {
@@ -186,7 +188,9 @@ function createTokenVerifier(options) {
186
188
  return null;
187
189
  }
188
190
  if (!response.ok) {
189
- if (response.status >= 500 && attempt < introspectRetries) continue;
191
+ if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {
192
+ continue;
193
+ }
190
194
  return null;
191
195
  }
192
196
  try {
@@ -222,7 +226,11 @@ function createTokenVerifier(options) {
222
226
  iss: issuer,
223
227
  client_id: data.client_id,
224
228
  scope: data.scope,
225
- exp: data.exp
229
+ exp: data.exp,
230
+ ...data.client_type ? { client_type: data.client_type } : {},
231
+ ...data.sid ? { sid: data.sid } : {},
232
+ ...typeof data.auth_time === "number" ? { auth_time: data.auth_time } : {},
233
+ ...data.cnf ? { cnf: data.cnf } : {}
226
234
  };
227
235
  }
228
236
  let ttl = introspectCacheTtl;
@@ -297,34 +305,63 @@ function createTokenVerifier(options) {
297
305
  return null;
298
306
  }
299
307
  }
308
+ const hasIntrospection = Boolean(introspectionEndpoint && clientId);
309
+ const rejectM2mPayload = (payload) => {
310
+ if (!payload || allowM2mTokens) return payload;
311
+ const isM2m = payload.client_type === "m2m" || payload.sub.startsWith("client:");
312
+ return isM2m ? null : payload;
313
+ };
314
+ const verifyLocallyBoth = async (rawToken) => {
315
+ if (accessTokenSecret) {
316
+ const local = await verifyLocally(rawToken);
317
+ if (local) return local;
318
+ }
319
+ return verifyWithRS256(rawToken);
320
+ };
321
+ const verifyViaIntrospection = async (rawToken) => {
322
+ const result = await introspect(rawToken);
323
+ if (result?.active !== true || !result.sub) return null;
324
+ return {
325
+ sub: result.sub,
326
+ aud: audience,
327
+ iss: issuer,
328
+ client_id: result.client_id,
329
+ scope: result.scope,
330
+ exp: result.exp,
331
+ ...result.client_type ? { client_type: result.client_type } : {},
332
+ ...result.sid ? { sid: result.sid } : {},
333
+ ...typeof result.auth_time === "number" ? { auth_time: result.auth_time } : {},
334
+ ...result.cnf ? { cnf: result.cnf } : {},
335
+ ...result.token_type ? { token_type: result.token_type } : {}
336
+ };
337
+ };
300
338
  return {
301
339
  /**
302
340
  * 验证 Access Token
303
341
  *
304
- * 验证顺序:
305
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
306
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
307
- * 3. 若上述均失败/未配置,调用 Introspection 端点
342
+ * 模式由 verificationMode 控制(默认 "auto"):
343
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
344
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
345
+ * - local:仅本地验签(不感知撤销)。
346
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
308
347
  *
309
348
  * @returns token payload 或者 null(验证失败)
310
349
  */
311
- async verify(token) {
312
- if (accessTokenSecret) {
313
- const local = await verifyLocally(token);
350
+ async verify(rawToken) {
351
+ const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));
352
+ const runIntrospection = async () => rejectM2mPayload(await verifyViaIntrospection(rawToken));
353
+ if (verificationMode === "local") {
354
+ return await runLocal();
355
+ }
356
+ if (verificationMode === "introspection") {
357
+ return hasIntrospection ? await runIntrospection() : await runLocal();
358
+ }
359
+ if (verificationMode === "hybrid") {
360
+ const local = await runLocal();
314
361
  if (local) return local;
362
+ return hasIntrospection ? await runIntrospection() : null;
315
363
  }
316
- const rs256Result = await verifyWithRS256(token);
317
- if (rs256Result) return rs256Result;
318
- const result = await introspect(token);
319
- if (result?.active !== true || !result.sub) return null;
320
- return {
321
- sub: result.sub,
322
- aud: audience,
323
- iss: issuer,
324
- client_id: result.client_id,
325
- scope: result.scope,
326
- exp: result.exp
327
- };
364
+ return hasIntrospection ? await runIntrospection() : await runLocal();
328
365
  },
329
366
  /**
330
367
  * 直接调用 Introspection 端点
@@ -358,6 +395,12 @@ function createTokenVerifier(options) {
358
395
  if (typeof payload.exp !== "number") {
359
396
  return null;
360
397
  }
398
+ if (typeof payload.iat !== "number") {
399
+ return null;
400
+ }
401
+ if (payload.nonce !== void 0) {
402
+ return null;
403
+ }
361
404
  const events = payload.events;
362
405
  const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
363
406
  if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-verify\r\n *\r\n * NIHPLOD 一网通 SSO Token 验证工具包\r\n *\r\n * 供子项目在后端验证主站签发的 Access Token。\r\n *\r\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\r\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\r\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\r\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\r\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\r\n *\r\n * 安装:npm install @nihplod/sso-verify\r\n *\r\n * 使用:\r\n * ```typescript\r\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\r\n * const verifier = createTokenVerifier({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"YOUR_CLIENT_SECRET\",\r\n * audience: \"advisor\",\r\n * });\r\n *\r\n * const payload = await verifier.verify(token);\r\n * if (payload) {\r\n * console.log(payload.sub); // 用户 ID\r\n * }\r\n * ```\r\n */\r\n\r\nimport {\r\n jwtVerify,\r\n importSPKI,\r\n createRemoteJWKSet,\r\n decodeProtectedHeader,\r\n type KeyLike,\r\n type JWTPayload,\r\n} from \"jose\";\r\nimport { LRUCache } from \"lru-cache\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoVerifierOptions {\r\n /** 期望的 audience(对应子项目的 client_id) */\r\n audience: string;\r\n\r\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\r\n issuer?: string;\r\n\r\n /**\r\n * Token Introspection 端点 URL。\r\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\r\n */\r\n introspectionEndpoint?: string;\r\n\r\n /** 子项目的 client_id(用于 introspection) */\r\n clientId?: string;\r\n\r\n /** 子项目的 client_secret(用于 introspection) */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 共享的 Access Token Secret(可选)。\r\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\r\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\r\n */\r\n accessTokenSecret?: string;\r\n\r\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\r\n introspectCacheTtl?: number;\r\n\r\n /**\r\n * Introspection 请求超时时间(毫秒),默认 10 秒。\r\n * 超时按验证失败处理(返回 null)。\r\n */\r\n introspectTimeoutMs?: number;\r\n\r\n /**\r\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\r\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\r\n * 设为 0 表示不缓存 active:false 结果。\r\n */\r\n introspectNegativeCacheTtl?: number;\r\n\r\n /**\r\n * Access Token RS256 公钥(PEM 格式,可选)。\r\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\r\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\r\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\r\n * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS\r\n * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。\r\n */\r\n accessTokenPublicKey?: string;\r\n\r\n /**\r\n * JWKS 端点 URL(可选)。\r\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\r\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;\r\n * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。\r\n */\r\n jwksUri?: string;\r\n\r\n /**\r\n * Logout Token Secret(可选)。\r\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\r\n * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token\r\n * 验证将失败(返回 null)。\r\n */\r\n logoutTokenSecret?: string;\r\n\r\n /**\r\n * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。\r\n *\r\n * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。\r\n * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),\r\n * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。\r\n * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。\r\n *\r\n * 开启后会在两处输出告警(console.warn):\r\n * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置\r\n * logoutTokenSecret)立即提示一次;\r\n * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发\r\n * (一次性告警)。\r\n */\r\n allowLegacyAccessTokenSecretFallback?: boolean;\r\n\r\n /**\r\n * Logout Token RS256 公钥(PEM 格式,可选)。\r\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\r\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\r\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;\r\n * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。\r\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\r\n * 不会静默回退到 HS256。\r\n */\r\n logoutTokenPublicKey?: string;\r\n\r\n /**\r\n * Introspection 请求失败重试次数,默认 1。\r\n * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。\r\n * 设为 0 表示不重试。\r\n */\r\n introspectRetries?: number;\r\n\r\n /**\r\n * JWT 时钟偏移容忍(秒),默认 60。\r\n * 应用于所有本地验签路径(HS256/RS256/logout token),\r\n * 用于容忍子项目与主站之间的时钟偏差。\r\n */\r\n clockToleranceSeconds?: number;\r\n\r\n /**\r\n * Logout Token jti 外部存储(可选)。\r\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\r\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\r\n */\r\n logoutJtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * Introspection audience 严格模式(默认 true,fail-closed)。\r\n *\r\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\r\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\r\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\r\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\r\n */\r\n strictAudience?: boolean;\r\n}\r\n\r\n/**\r\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\r\n *\r\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\r\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\r\n */\r\nexport interface LogoutJtiStore {\r\n /** 判断 jti 是否已处理过 */\r\n has(key: string): boolean | Promise<boolean>;\r\n /** 记录已处理的 jti,ttlSeconds 后过期 */\r\n add(key: string, ttlSeconds: number): void | Promise<void>;\r\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\r\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\r\n}\r\n\r\nexport interface VerifiedTokenPayload extends JWTPayload {\r\n sub: string;\r\n aud: string;\r\n iss: string;\r\n client_id?: string;\r\n scope?: string;\r\n phone?: string;\r\n}\r\n\r\n/**\r\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\r\n *\r\n * 主站签发 logout_token 时包含以下字段:\r\n * - iss: 主站 issuer\r\n * - aud: 目标 client_id\r\n * - sub: 用户 ID(与 sid 至少其一)\r\n * - iat: 签发时间\r\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\r\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\r\n */\r\nexport interface LogoutTokenPayload {\r\n /** JWT type,logout_token 固定值为 \"logout_token\" */\r\n type: \"logout_token\";\r\n /** 签发者 */\r\n iss: string;\r\n /** 目标 audience */\r\n aud: string;\r\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\r\n sub?: string;\r\n /** 签发时间(UNIX timestamp) */\r\n iat: number;\r\n /** JWT ID(唯一标识,防重放) */\r\n jti: string;\r\n /** Backchannel logout 事件声明 */\r\n events: {\r\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\r\n };\r\n /** sid (Session ID),可选 */\r\n sid?: string;\r\n}\r\n\r\ninterface IntrospectResponse {\r\n active: boolean;\r\n sub?: string;\r\n aud?: string | string[];\r\n client_id?: string;\r\n scope?: string;\r\n exp?: number;\r\n [key: string]: unknown;\r\n}\r\n\r\n// ============================================\r\n// Introspection 结果缓存\r\n// ============================================\r\n\r\ninterface IntrospectCacheEntry {\r\n active: boolean;\r\n payload: VerifiedTokenPayload | null;\r\n}\r\n\r\nfunction createIntrospectCache(ttlMs: number) {\r\n return new LRUCache<string, IntrospectCacheEntry>({\r\n max: 10000,\r\n ttl: ttlMs,\r\n });\r\n}\r\n\r\n// ============================================\r\n// Logout Token jti 重放防护缓存\r\n// ============================================\r\n\r\n/**\r\n * 已处理的 logout_token jti 缓存。\r\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\r\n */\r\nconst processedLogoutJtis = new LRUCache<string, number>({\r\n max: 10000,\r\n ttl: 10 * 60 * 1000,\r\n});\r\n\r\n// 废弃的 HS256 logout_token 回退(accessTokenSecret 代验)告警文案,\r\n// 构造时与首次实际使用时复用同一条消息\r\nconst LEGACY_LOGOUT_SECRET_FALLBACK_WARNING =\r\n \"[sso-verify] 正在使用 accessTokenSecret 验证 HS256 logout_token(废弃路径):\" +\r\n \"共享 access secret 的服务可伪造任意用户登出。\" +\r\n \"请改用 logoutTokenSecret,或配置 logoutTokenPublicKey/jwksUri 走 RS256。\";\r\n\r\n// 非生产环境下 http:// 非 loopback 端点的告警去重(每个端点名每进程只提示一次)\r\nconst warnedInsecureEndpoints = new Set<string>();\r\n\r\n// ============================================\r\n// Token Verifier\r\n// ============================================\r\n\r\nexport function createTokenVerifier(options: SsoVerifierOptions) {\r\n const {\r\n audience,\r\n issuer = \"https://nihplod.cn\",\r\n introspectionEndpoint,\r\n clientId,\r\n clientSecret,\r\n accessTokenSecret,\r\n accessTokenPublicKey,\r\n jwksUri,\r\n logoutTokenSecret,\r\n logoutTokenPublicKey,\r\n allowLegacyAccessTokenSecretFallback = false,\r\n introspectCacheTtl = 30 * 1000,\r\n introspectTimeoutMs = 10 * 1000,\r\n introspectNegativeCacheTtl = 5 * 1000,\r\n introspectRetries = 1,\r\n clockToleranceSeconds = 60,\r\n logoutJtiStore,\r\n strictAudience = true,\r\n } = options;\r\n\r\n // 一次性告警标志:使用废弃的 accessTokenSecret 回退验证 logout_token 时提示迁移\r\n let warnedLegacyLogoutSecret = false;\r\n\r\n // 构造时即提示:显式开启回退且该路径实际可生效(有 accessTokenSecret、无 logoutTokenSecret)\r\n if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n\r\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\r\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\r\n // 非生产环境下 http:// 非 loopback 端点输出一次性告警(不阻断本地联调)。\r\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\r\n if (!value) return;\r\n let url: URL;\r\n try {\r\n url = new URL(value);\r\n } catch {\r\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\r\n }\r\n if (url.protocol === \"https:\") return;\r\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\r\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\r\n if (url.protocol === \"http:\" && isLoopback) return;\r\n if (process.env.NODE_ENV !== \"production\") {\r\n if (url.protocol === \"http:\" && !warnedInsecureEndpoints.has(name)) {\r\n warnedInsecureEndpoints.add(name);\r\n console.warn(\r\n `[sso-verify] ${name} 使用非加密的 http:// 且非 loopback 地址;` +\r\n \"生产环境(NODE_ENV=production)将直接抛错\"\r\n );\r\n }\r\n return;\r\n }\r\n throw new Error(\r\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\r\n \"避免凭证/令牌经明文传输\"\r\n );\r\n };\r\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\r\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\r\n\r\n const introspectCache = createIntrospectCache(introspectCacheTtl);\r\n\r\n // RS256 JWKS 远程密钥集(延迟初始化)\r\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\r\n function getJwksKeySet() {\r\n if (!jwksUri) return null;\r\n if (!_jwksKeySet) {\r\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\r\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\r\n });\r\n }\r\n return _jwksKeySet;\r\n }\r\n\r\n // RS256 公钥(延迟导入)\r\n let _rs256PublicKey: KeyLike | null = null;\r\n let _rs256KeyInitialized = false;\r\n async function getRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_rs256KeyInitialized) {\r\n _rs256KeyInitialized = true;\r\n if (!accessTokenPublicKey) {\r\n _rs256PublicKey = null;\r\n } else {\r\n try {\r\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\r\n } catch {\r\n _rs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _rs256PublicKey;\r\n }\r\n\r\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\r\n let _logoutRs256PublicKey: KeyLike | null = null;\r\n let _logoutRs256KeyInitialized = false;\r\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_logoutRs256KeyInitialized) {\r\n _logoutRs256KeyInitialized = true;\r\n if (!logoutTokenPublicKey) {\r\n _logoutRs256PublicKey = null;\r\n } else {\r\n try {\r\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\r\n } catch {\r\n _logoutRs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _logoutRs256PublicKey;\r\n }\r\n\r\n /**\r\n * Introspection 响应的 aud 归属校验(防 confused deputy):\r\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\r\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\r\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\r\n * 默认 strictAudience=true 时 fail-closed 拒绝。\r\n */\r\n function matchesAudience(data: IntrospectResponse): boolean {\r\n const aud = data.aud;\r\n if (typeof aud === \"string\") return aud === audience;\r\n if (Array.isArray(aud)) return aud.includes(audience);\r\n if (typeof data.client_id === \"string\") return data.client_id === audience;\r\n return !strictAudience;\r\n }\r\n\r\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\r\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\r\n\r\n /**\r\n * 调用主站 Introspection 端点验证 token\r\n *\r\n * Public Client 可省略 client_secret,仅传 client_id。\r\n * Confidential Client 建议传入 client_secret 以通过端点认证。\r\n */\r\n async function introspect(token: string): Promise<IntrospectResponse | null> {\r\n if (!introspectionEndpoint || !clientId) {\r\n return null;\r\n }\r\n\r\n const cached = introspectCache.get(token);\r\n if (cached) {\r\n return { active: cached.active, ...(cached.payload || {}) };\r\n }\r\n\r\n const inflight = inflightIntrospects.get(token);\r\n if (inflight) {\r\n return inflight;\r\n }\r\n\r\n const promise = doIntrospect(token).finally(() => {\r\n inflightIntrospects.delete(token);\r\n });\r\n inflightIntrospects.set(token, promise);\r\n return promise;\r\n }\r\n\r\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\r\n const params = new URLSearchParams({ token, client_id: clientId! });\r\n if (clientSecret) {\r\n params.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\r\n let data: IntrospectResponse | null = null;\r\n for (let attempt = 0; ; attempt++) {\r\n if (attempt > 0) {\r\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\r\n }\r\n let response: Response;\r\n try {\r\n response = await fetch(introspectionEndpoint!, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: params,\r\n signal: AbortSignal.timeout(introspectTimeoutMs),\r\n });\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n if (!response.ok) {\r\n if (response.status >= 500 && attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\r\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\r\n try {\r\n data = (await response.json()) as IntrospectResponse;\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n break;\r\n }\r\n if (!data) return null;\r\n\r\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\r\n if (data.active !== true) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\r\n if (!matchesAudience(data)) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n let payload: VerifiedTokenPayload | null = null;\r\n if (data.sub) {\r\n payload = {\r\n sub: data.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: data.client_id,\r\n scope: data.scope,\r\n exp: data.exp,\r\n } as VerifiedTokenPayload;\r\n }\r\n\r\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\r\n // 已过期/无剩余有效期时不缓存。\r\n let ttl = introspectCacheTtl;\r\n if (typeof data.exp === \"number\") {\r\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\r\n }\r\n if (ttl > 0) {\r\n introspectCache.set(token, { active: true, payload }, { ttl });\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\r\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\r\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\r\n * (session-init / getSessionUser 等)一律读 payload.sub。\r\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\r\n */\r\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\r\n const p = payload as VerifiedTokenPayload & { id?: string };\r\n if (!p.sub && typeof p.id === \"string\" && p.id) {\r\n p.sub = p.id;\r\n }\r\n return p.sub ? p : null;\r\n }\r\n\r\n /**\r\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\r\n */\r\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\r\n if (!accessTokenSecret) return null;\r\n\r\n try {\r\n const secret = new TextEncoder().encode(accessTokenSecret);\r\n const { payload } = await jwtVerify(token, secret, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n\r\n if ((payload as { type?: string }).type !== \"access_token\") {\r\n return null;\r\n }\r\n\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * RS256 本地验证(优先直接公钥,验证失败回退 JWKS 远程获取)\r\n */\r\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 检查是否有 RS256 验证能力\r\n const hasPublicKey = Boolean(accessTokenPublicKey);\r\n const hasJwks = Boolean(jwksUri);\r\n if (!hasPublicKey && !hasJwks) return null;\r\n\r\n try {\r\n // 优先使用直接公钥;验证失败(如主站轮换密钥后本地公钥过期)\r\n // 时回退 JWKS,而不是直接判定失败\r\n const directKey = await getRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // 回退到 JWKS 远程获取\r\n const jwks = getJwksKeySet();\r\n if (jwks) {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n }\r\n\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return {\r\n /**\r\n * 验证 Access Token\r\n *\r\n * 验证顺序:\r\n * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证\r\n * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证\r\n * 3. 若上述均失败/未配置,调用 Introspection 端点\r\n *\r\n * @returns token payload 或者 null(验证失败)\r\n */\r\n async verify(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 1. HS256 本地验证(优先速度,适用于共享 secret 的内部服务)\r\n if (accessTokenSecret) {\r\n const local = await verifyLocally(token);\r\n if (local) return local;\r\n }\r\n\r\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\r\n const rs256Result = await verifyWithRS256(token);\r\n if (rs256Result) return rs256Result;\r\n\r\n // 3. Introspection 验证(兜底方案)\r\n const result = await introspect(token);\r\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\r\n if (result?.active !== true || !result.sub) return null;\r\n\r\n return {\r\n sub: result.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: result.client_id,\r\n scope: result.scope,\r\n exp: result.exp,\r\n } as VerifiedTokenPayload;\r\n },\r\n\r\n /**\r\n * 直接调用 Introspection 端点\r\n */\r\n introspect,\r\n\r\n /**\r\n * 清除指定 token 的 Introspection 缓存\r\n */\r\n invalidateCache(token: string): void {\r\n introspectCache.delete(token);\r\n },\r\n\r\n /**\r\n * 验证 Logout Token(Back-Channel Logout)\r\n *\r\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\r\n * 验证要求(OIDC Back-Channel Logout 1.0):\r\n * 1. type === \"logout_token\"\r\n * 2. iss 匹配配置的 issuer\r\n * 3. aud 包含当前 client_id\r\n * 4. exp 存在(规范要求)\r\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\r\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\r\n *\r\n * @returns LogoutTokenPayload 或 null(验证失败)\r\n */\r\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\r\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\r\n const validateLogoutPayload = async (\r\n payload: Record<string, unknown>\r\n ): Promise<LogoutTokenPayload | null> => {\r\n if ((payload as { type?: string }).type !== \"logout_token\") {\r\n return null;\r\n }\r\n // 规范要求 logout_token 必须携带 exp\r\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\r\n return null;\r\n }\r\n const events = (payload as { events?: Record<string, unknown> }).events;\r\n const logoutEvent =\r\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\r\n // 事件值必须是对象(通常为空对象 {})\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n return null;\r\n }\r\n const jti = (payload as { jti?: string }).jti;\r\n if (!jti || typeof jti !== \"string\") {\r\n return null;\r\n }\r\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\r\n // 否则无法定位要终止的会话/用户,按验证失败处理\r\n const sub = (payload as { sub?: unknown }).sub;\r\n const sid = (payload as { sid?: unknown }).sid;\r\n const hasSub = typeof sub === \"string\" && sub.length > 0;\r\n const hasSid = typeof sid === \"string\" && sid.length > 0;\r\n if (!hasSub && !hasSid) {\r\n return null;\r\n }\r\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\r\n const iss = (payload as { iss?: string }).iss || issuer;\r\n const jtiKey = `${iss}:${jti}`;\r\n if (logoutJtiStore) {\r\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\r\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\r\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\r\n if (!firstUse) return null;\r\n } else {\r\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\r\n // 多实例部署请补充 addIfAbsent 实现\r\n if (await logoutJtiStore.has(jtiKey)) {\r\n return null;\r\n }\r\n await logoutJtiStore.add(jtiKey, 10 * 60);\r\n }\r\n } else {\r\n if (processedLogoutJtis.has(jtiKey)) {\r\n return null;\r\n }\r\n processedLogoutJtis.set(jtiKey, Date.now());\r\n }\r\n return payload as unknown as LogoutTokenPayload;\r\n };\r\n\r\n // 按 JWT header alg 分发,避免跨算法静默回退\r\n let alg: string | undefined;\r\n try {\r\n alg = decodeProtectedHeader(token).alg;\r\n } catch {\r\n return null;\r\n }\r\n\r\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\r\n // 直接公钥验证失败(如密钥轮换后本地公钥过期)时回退 JWKS;\r\n // 无任何可用公钥时直接失败,不回退 HS256。\r\n if (alg === \"RS256\") {\r\n const directKey = await getLogoutRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\r\n // 与 verify() 一致按验证失败处理,而不是异常穿透\r\n let jwks: ReturnType<typeof getJwksKeySet> = null;\r\n try {\r\n jwks = getJwksKeySet();\r\n } catch {\r\n return null;\r\n }\r\n if (jwks) {\r\n try {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n // 2. HS256:使用 logoutTokenSecret(专用密钥);回退 accessTokenSecret 默认关闭\r\n if (alg === \"HS256\") {\r\n const secret =\r\n logoutTokenSecret ||\r\n (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : undefined);\r\n if (!secret) return null;\r\n if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {\r\n if (!warnedLegacyLogoutSecret) {\r\n warnedLegacyLogoutSecret = true;\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n }\r\n\r\n try {\r\n const key = new TextEncoder().encode(secret);\r\n const { payload } = await jwtVerify(token, key, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n },\r\n };\r\n}\r\n\r\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\r\nexport interface SsoMiddlewareRequest {\r\n headers?: Record<string, string | undefined>;\r\n /** 验证通过后由中间件挂载的用户信息 */\r\n user?: VerifiedTokenPayload;\r\n [key: string]: unknown;\r\n}\r\n\r\n/** 框架无关的最小中间件响应类型 */\r\nexport interface SsoMiddlewareResponse {\r\n status?: (code: number) => { json: (body: unknown) => unknown };\r\n [key: string]: unknown;\r\n}\r\n\r\n/**\r\n * Express/Next.js 兼容中间件\r\n *\r\n * 使用示例(Express):\r\n * ```typescript\r\n * app.use(ssoMiddleware({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"SECRET\",\r\n * audience: \"advisor\",\r\n * }));\r\n * ```\r\n *\r\n * 使用示例(Next.js Route Handler):\r\n * ```typescript\r\n * const verifier = createTokenVerifier({ ... });\r\n * export async function GET(request: NextRequest) {\r\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\r\n * const payload = token ? await verifier.verify(token) : null;\r\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\r\n * // ...\r\n * }\r\n * ```\r\n */\r\nexport function ssoMiddleware(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\r\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\r\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\r\n\r\n if (!token) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\r\n return;\r\n }\r\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\r\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\r\n return;\r\n }\r\n\r\n const payload = await verifier.verify(token);\r\n if (!payload) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\r\n return;\r\n }\r\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\r\n return;\r\n }\r\n\r\n // 挂载用户信息到请求对象\r\n req.user = payload;\r\n next();\r\n };\r\n}\r\n\r\n/**\r\n * 创建 Logout Token 专用验证器\r\n *\r\n * 适用于仅需处理 backchannel logout 的子项目。\r\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\r\n *\r\n * 使用示例:\r\n * ```typescript\r\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\r\n *\r\n * const logoutVerifier = createLogoutTokenVerifier({\r\n * audience: \"advisor\",\r\n * issuer: \"https://nihplod.cn\",\r\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\r\n * });\r\n *\r\n * // 在 backchannel logout 端点中:\r\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\r\n * const token = req.body.logout_token;\r\n * const payload = await logoutVerifier.verify(token);\r\n * if (payload) {\r\n * await clearUserSession(payload.sub);\r\n * res.status(200).end();\r\n * } else {\r\n * res.status(400).end();\r\n * }\r\n * });\r\n * ```\r\n */\r\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return {\r\n /**\r\n * 验证 logout_token\r\n */\r\n async verify(token: string): Promise<LogoutTokenPayload | null> {\r\n return verifier.verifyLogoutToken(token);\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-verify\r\n *\r\n * NIHPLOD 一网通 SSO Token 验证工具包\r\n *\r\n * 供子项目在后端验证主站签发的 Access Token。\r\n *\r\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\r\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\r\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\r\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\r\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\r\n *\r\n * 安装:npm install @nihplod/sso-verify\r\n *\r\n * 使用:\r\n * ```typescript\r\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\r\n * const verifier = createTokenVerifier({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"YOUR_CLIENT_SECRET\",\r\n * audience: \"advisor\",\r\n * });\r\n *\r\n * const payload = await verifier.verify(token);\r\n * if (payload) {\r\n * console.log(payload.sub); // 用户 ID\r\n * }\r\n * ```\r\n */\r\n\r\nimport {\r\n jwtVerify,\r\n importSPKI,\r\n createRemoteJWKSet,\r\n decodeProtectedHeader,\r\n type KeyLike,\r\n type JWTPayload,\r\n} from \"jose\";\r\nimport { LRUCache } from \"lru-cache\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoVerifierOptions {\r\n /** 期望的 audience(对应子项目的 client_id) */\r\n audience: string;\r\n\r\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\r\n issuer?: string;\r\n\r\n /**\r\n * Token Introspection 端点 URL。\r\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\r\n */\r\n introspectionEndpoint?: string;\r\n\r\n /** 子项目的 client_id(用于 introspection) */\r\n clientId?: string;\r\n\r\n /** 子项目的 client_secret(用于 introspection) */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 共享的 Access Token Secret(可选)。\r\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\r\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\r\n */\r\n accessTokenSecret?: string;\r\n\r\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\r\n introspectCacheTtl?: number;\r\n\r\n /**\r\n * Introspection 请求超时时间(毫秒),默认 10 秒。\r\n * 超时按验证失败处理(返回 null)。\r\n */\r\n introspectTimeoutMs?: number;\r\n\r\n /**\r\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\r\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\r\n * 设为 0 表示不缓存 active:false 结果。\r\n */\r\n introspectNegativeCacheTtl?: number;\r\n\r\n /**\r\n * Access Token RS256 公钥(PEM 格式,可选)。\r\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\r\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\r\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\r\n * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS\r\n * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。\r\n */\r\n accessTokenPublicKey?: string;\r\n\r\n /**\r\n * JWKS 端点 URL(可选)。\r\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\r\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;\r\n * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。\r\n */\r\n jwksUri?: string;\r\n\r\n /**\r\n * Logout Token Secret(可选)。\r\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\r\n * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token\r\n * 验证将失败(返回 null)。\r\n */\r\n logoutTokenSecret?: string;\r\n\r\n /**\r\n * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。\r\n *\r\n * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。\r\n * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),\r\n * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。\r\n * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。\r\n *\r\n * 开启后会在两处输出告警(console.warn):\r\n * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置\r\n * logoutTokenSecret)立即提示一次;\r\n * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发\r\n * (一次性告警)。\r\n */\r\n allowLegacyAccessTokenSecretFallback?: boolean;\r\n\r\n /**\r\n * Logout Token RS256 公钥(PEM 格式,可选)。\r\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\r\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\r\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;\r\n * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。\r\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\r\n * 不会静默回退到 HS256。\r\n */\r\n logoutTokenPublicKey?: string;\r\n\r\n /**\r\n * Introspection 请求失败重试次数,默认 1。\r\n * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。\r\n * 设为 0 表示不重试。\r\n */\r\n introspectRetries?: number;\r\n\r\n /**\r\n * JWT 时钟偏移容忍(秒),默认 60。\r\n * 应用于所有本地验签路径(HS256/RS256/logout token),\r\n * 用于容忍子项目与主站之间的时钟偏差。\r\n */\r\n clockToleranceSeconds?: number;\r\n\r\n /**\r\n * Logout Token jti 外部存储(可选)。\r\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\r\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\r\n */\r\n logoutJtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * Introspection audience 严格模式(默认 true,fail-closed)。\r\n *\r\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\r\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\r\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\r\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\r\n */\r\n strictAudience?: boolean;\r\n\r\n /**\r\n * 验证模式(默认 \"auto\"):\r\n * - \"auto\":配置了 introspectionEndpoint + clientId 时使用 Introspection(可实时感知撤销);\r\n * 未配置时回退本地 JWT 验签。\r\n * - \"introspection\":强制 Introspection;未配置端点时回退本地验签。\r\n * - \"local\":仅本地 HS256/RS256 验签(不感知撤销,仅建议非敏感、高吞吐场景)。\r\n * - \"hybrid\":先本地验签,失败后再 Introspection(旧版行为,性能优先)。\r\n *\r\n * 涉及账户状态/授权撤销的鉴权建议使用 \"auto\" 或 \"introspection\"。\r\n */\r\n verificationMode?: \"auto\" | \"introspection\" | \"local\" | \"hybrid\";\r\n\r\n /**\r\n * 是否允许 M2M(client_credentials)token 通过用户态校验(默认 false)。\r\n * M2M token 的 sub 为 `client:<client_id>`,不是用户 ID;若业务确实需要\r\n * 校验 M2M token,请显式设为 true 并自行区分 client_type。\r\n */\r\n allowM2mTokens?: boolean;\r\n}\r\n\r\n/**\r\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\r\n *\r\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\r\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\r\n */\r\nexport interface LogoutJtiStore {\r\n /** 判断 jti 是否已处理过 */\r\n has(key: string): boolean | Promise<boolean>;\r\n /** 记录已处理的 jti,ttlSeconds 后过期 */\r\n add(key: string, ttlSeconds: number): void | Promise<void>;\r\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\r\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\r\n}\r\n\r\nexport interface VerifiedTokenPayload extends JWTPayload {\r\n sub: string;\r\n aud: string;\r\n iss: string;\r\n client_id?: string;\r\n scope?: string;\r\n phone?: string;\r\n /** 身份类型:m2m = client_credentials,user = 用户授权码流程 */\r\n client_type?: \"m2m\" | \"user\";\r\n /** OAuthSession ID;携带 sid 的 token 可被会话级撤销 */\r\n sid?: string;\r\n /** 原始认证时间(Unix 秒),供 max_age 判断 */\r\n auth_time?: number;\r\n /** DPoP 绑定(RFC 9449 cnf.jkt) */\r\n cnf?: { jkt?: string };\r\n /** introspection 返回的 token 类型(Bearer / DPoP) */\r\n token_type?: \"Bearer\" | \"DPoP\";\r\n}\r\n\r\n/**\r\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\r\n *\r\n * 主站签发 logout_token 时包含以下字段:\r\n * - iss: 主站 issuer\r\n * - aud: 目标 client_id\r\n * - sub: 用户 ID(与 sid 至少其一)\r\n * - iat: 签发时间\r\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\r\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\r\n */\r\nexport interface LogoutTokenPayload {\r\n /** JWT type,logout_token 固定值为 \"logout_token\" */\r\n type: \"logout_token\";\r\n /** 签发者 */\r\n iss: string;\r\n /** 目标 audience */\r\n aud: string;\r\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\r\n sub?: string;\r\n /** 签发时间(UNIX timestamp) */\r\n iat: number;\r\n /** JWT ID(唯一标识,防重放) */\r\n jti: string;\r\n /** Backchannel logout 事件声明 */\r\n events: {\r\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\r\n };\r\n /** sid (Session ID),可选 */\r\n sid?: string;\r\n}\r\n\r\ninterface IntrospectResponse {\r\n active: boolean;\r\n sub?: string;\r\n aud?: string | string[];\r\n client_id?: string;\r\n scope?: string;\r\n exp?: number;\r\n token_type?: string;\r\n client_type?: \"m2m\" | \"user\";\r\n sid?: string;\r\n auth_time?: number;\r\n cnf?: { jkt?: string };\r\n [key: string]: unknown;\r\n}\r\n\r\n// ============================================\r\n// Introspection 结果缓存\r\n// ============================================\r\n\r\ninterface IntrospectCacheEntry {\r\n active: boolean;\r\n payload: VerifiedTokenPayload | null;\r\n}\r\n\r\nfunction createIntrospectCache(ttlMs: number) {\r\n return new LRUCache<string, IntrospectCacheEntry>({\r\n max: 10000,\r\n ttl: ttlMs,\r\n });\r\n}\r\n\r\n// ============================================\r\n// Logout Token jti 重放防护缓存\r\n// ============================================\r\n\r\n/**\r\n * 已处理的 logout_token jti 缓存。\r\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\r\n */\r\nconst processedLogoutJtis = new LRUCache<string, number>({\r\n max: 10000,\r\n ttl: 10 * 60 * 1000,\r\n});\r\n\r\n// 废弃的 HS256 logout_token 回退(accessTokenSecret 代验)告警文案,\r\n// 构造时与首次实际使用时复用同一条消息\r\nconst LEGACY_LOGOUT_SECRET_FALLBACK_WARNING =\r\n \"[sso-verify] 正在使用 accessTokenSecret 验证 HS256 logout_token(废弃路径):\" +\r\n \"共享 access secret 的服务可伪造任意用户登出。\" +\r\n \"请改用 logoutTokenSecret,或配置 logoutTokenPublicKey/jwksUri 走 RS256。\";\r\n\r\n// 非生产环境下 http:// 非 loopback 端点的告警去重(每个端点名每进程只提示一次)\r\nconst warnedInsecureEndpoints = new Set<string>();\r\n\r\n// ============================================\r\n// Token Verifier\r\n// ============================================\r\n\r\nexport function createTokenVerifier(options: SsoVerifierOptions) {\r\n const {\r\n audience,\r\n issuer = \"https://nihplod.cn\",\r\n introspectionEndpoint,\r\n clientId,\r\n clientSecret,\r\n accessTokenSecret,\r\n accessTokenPublicKey,\r\n jwksUri,\r\n logoutTokenSecret,\r\n logoutTokenPublicKey,\r\n allowLegacyAccessTokenSecretFallback = false,\r\n introspectCacheTtl = 30 * 1000,\r\n introspectTimeoutMs = 10 * 1000,\r\n introspectNegativeCacheTtl = 5 * 1000,\r\n introspectRetries = 1,\r\n clockToleranceSeconds = 60,\r\n logoutJtiStore,\r\n strictAudience = true,\r\n verificationMode = \"auto\",\r\n allowM2mTokens = false,\r\n } = options;\r\n\r\n // 一次性告警标志:使用废弃的 accessTokenSecret 回退验证 logout_token 时提示迁移\r\n let warnedLegacyLogoutSecret = false;\r\n\r\n // 构造时即提示:显式开启回退且该路径实际可生效(有 accessTokenSecret、无 logoutTokenSecret)\r\n if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n\r\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\r\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\r\n // 非生产环境下 http:// 非 loopback 端点输出一次性告警(不阻断本地联调)。\r\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\r\n if (!value) return;\r\n let url: URL;\r\n try {\r\n url = new URL(value);\r\n } catch {\r\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\r\n }\r\n if (url.protocol === \"https:\") return;\r\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\r\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\r\n if (url.protocol === \"http:\" && isLoopback) return;\r\n if (process.env.NODE_ENV !== \"production\") {\r\n if (url.protocol === \"http:\" && !warnedInsecureEndpoints.has(name)) {\r\n warnedInsecureEndpoints.add(name);\r\n console.warn(\r\n `[sso-verify] ${name} 使用非加密的 http:// 且非 loopback 地址;` +\r\n \"生产环境(NODE_ENV=production)将直接抛错\"\r\n );\r\n }\r\n return;\r\n }\r\n throw new Error(\r\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\r\n \"避免凭证/令牌经明文传输\"\r\n );\r\n };\r\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\r\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\r\n\r\n const introspectCache = createIntrospectCache(introspectCacheTtl);\r\n\r\n // RS256 JWKS 远程密钥集(延迟初始化)\r\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\r\n function getJwksKeySet() {\r\n if (!jwksUri) return null;\r\n if (!_jwksKeySet) {\r\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\r\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\r\n });\r\n }\r\n return _jwksKeySet;\r\n }\r\n\r\n // RS256 公钥(延迟导入)\r\n let _rs256PublicKey: KeyLike | null = null;\r\n let _rs256KeyInitialized = false;\r\n async function getRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_rs256KeyInitialized) {\r\n _rs256KeyInitialized = true;\r\n if (!accessTokenPublicKey) {\r\n _rs256PublicKey = null;\r\n } else {\r\n try {\r\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\r\n } catch {\r\n _rs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _rs256PublicKey;\r\n }\r\n\r\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\r\n let _logoutRs256PublicKey: KeyLike | null = null;\r\n let _logoutRs256KeyInitialized = false;\r\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_logoutRs256KeyInitialized) {\r\n _logoutRs256KeyInitialized = true;\r\n if (!logoutTokenPublicKey) {\r\n _logoutRs256PublicKey = null;\r\n } else {\r\n try {\r\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\r\n } catch {\r\n _logoutRs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _logoutRs256PublicKey;\r\n }\r\n\r\n /**\r\n * Introspection 响应的 aud 归属校验(防 confused deputy):\r\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\r\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\r\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\r\n * 默认 strictAudience=true 时 fail-closed 拒绝。\r\n */\r\n function matchesAudience(data: IntrospectResponse): boolean {\r\n const aud = data.aud;\r\n if (typeof aud === \"string\") return aud === audience;\r\n if (Array.isArray(aud)) return aud.includes(audience);\r\n if (typeof data.client_id === \"string\") return data.client_id === audience;\r\n return !strictAudience;\r\n }\r\n\r\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\r\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\r\n\r\n /**\r\n * 调用主站 Introspection 端点验证 token\r\n *\r\n * Public Client 可省略 client_secret,仅传 client_id。\r\n * Confidential Client 建议传入 client_secret 以通过端点认证。\r\n */\r\n async function introspect(token: string): Promise<IntrospectResponse | null> {\r\n if (!introspectionEndpoint || !clientId) {\r\n return null;\r\n }\r\n\r\n const cached = introspectCache.get(token);\r\n if (cached) {\r\n return { active: cached.active, ...(cached.payload || {}) };\r\n }\r\n\r\n const inflight = inflightIntrospects.get(token);\r\n if (inflight) {\r\n return inflight;\r\n }\r\n\r\n const promise = doIntrospect(token).finally(() => {\r\n inflightIntrospects.delete(token);\r\n });\r\n inflightIntrospects.set(token, promise);\r\n return promise;\r\n }\r\n\r\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\r\n const params = new URLSearchParams({ token, client_id: clientId! });\r\n if (clientSecret) {\r\n params.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\r\n let data: IntrospectResponse | null = null;\r\n for (let attempt = 0; ; attempt++) {\r\n if (attempt > 0) {\r\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\r\n }\r\n let response: Response;\r\n try {\r\n response = await fetch(introspectionEndpoint!, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: params,\r\n signal: AbortSignal.timeout(introspectTimeoutMs),\r\n });\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n if (!response.ok) {\r\n // 5xx 与 429(限流)可重试;429 通常需要更长窗口,但仍先短退避尝试一次\r\n if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {\r\n continue;\r\n }\r\n return null;\r\n }\r\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\r\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\r\n try {\r\n data = (await response.json()) as IntrospectResponse;\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n break;\r\n }\r\n if (!data) return null;\r\n\r\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\r\n if (data.active !== true) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\r\n if (!matchesAudience(data)) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n let payload: VerifiedTokenPayload | null = null;\r\n if (data.sub) {\r\n payload = {\r\n sub: data.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: data.client_id,\r\n scope: data.scope,\r\n exp: data.exp,\r\n ...(data.client_type ? { client_type: data.client_type } : {}),\r\n ...(data.sid ? { sid: data.sid } : {}),\r\n ...(typeof data.auth_time === \"number\" ? { auth_time: data.auth_time } : {}),\r\n ...(data.cnf ? { cnf: data.cnf } : {}),\r\n } as VerifiedTokenPayload;\r\n }\r\n\r\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\r\n // 已过期/无剩余有效期时不缓存。\r\n let ttl = introspectCacheTtl;\r\n if (typeof data.exp === \"number\") {\r\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\r\n }\r\n if (ttl > 0) {\r\n introspectCache.set(token, { active: true, payload }, { ttl });\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\r\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\r\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\r\n * (session-init / getSessionUser 等)一律读 payload.sub。\r\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\r\n */\r\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\r\n const p = payload as VerifiedTokenPayload & { id?: string };\r\n if (!p.sub && typeof p.id === \"string\" && p.id) {\r\n p.sub = p.id;\r\n }\r\n return p.sub ? p : null;\r\n }\r\n\r\n /**\r\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\r\n */\r\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\r\n if (!accessTokenSecret) return null;\r\n\r\n try {\r\n const secret = new TextEncoder().encode(accessTokenSecret);\r\n const { payload } = await jwtVerify(token, secret, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n\r\n if ((payload as { type?: string }).type !== \"access_token\") {\r\n return null;\r\n }\r\n\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * RS256 本地验证(优先直接公钥,验证失败回退 JWKS 远程获取)\r\n */\r\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 检查是否有 RS256 验证能力\r\n const hasPublicKey = Boolean(accessTokenPublicKey);\r\n const hasJwks = Boolean(jwksUri);\r\n if (!hasPublicKey && !hasJwks) return null;\r\n\r\n try {\r\n // 优先使用直接公钥;验证失败(如主站轮换密钥后本地公钥过期)\r\n // 时回退 JWKS,而不是直接判定失败\r\n const directKey = await getRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // 回退到 JWKS 远程获取\r\n const jwks = getJwksKeySet();\r\n if (jwks) {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n }\r\n\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n const hasIntrospection = Boolean(introspectionEndpoint && clientId);\r\n\r\n /**\r\n * M2M token 默认拒绝用户态校验:其 sub 是 `client:<id>`,不是用户 ID。\r\n * 需要校验 M2M token 的服务显式设置 allowM2mTokens: true,并自行区分 client_type。\r\n */\r\n const rejectM2mPayload = (payload: VerifiedTokenPayload | null): VerifiedTokenPayload | null => {\r\n if (!payload || allowM2mTokens) return payload;\r\n const isM2m = payload.client_type === \"m2m\" || payload.sub.startsWith(\"client:\");\r\n return isM2m ? null : payload;\r\n };\r\n\r\n const verifyLocallyBoth = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n // 1. HS256 本地验证(适用于共享 secret 的内部服务)\r\n if (accessTokenSecret) {\r\n const local = await verifyLocally(rawToken);\r\n if (local) return local;\r\n }\r\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\r\n return verifyWithRS256(rawToken);\r\n };\r\n\r\n const verifyViaIntrospection = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n const result = await introspect(rawToken);\r\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\r\n if (result?.active !== true || !result.sub) return null;\r\n return {\r\n sub: result.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: result.client_id,\r\n scope: result.scope,\r\n exp: result.exp,\r\n ...(result.client_type ? { client_type: result.client_type } : {}),\r\n ...(result.sid ? { sid: result.sid } : {}),\r\n ...(typeof result.auth_time === \"number\" ? { auth_time: result.auth_time } : {}),\r\n ...(result.cnf ? { cnf: result.cnf } : {}),\r\n ...(result.token_type ? { token_type: result.token_type } : {}),\r\n } as VerifiedTokenPayload;\r\n };\r\n\r\n return {\r\n /**\r\n * 验证 Access Token\r\n *\r\n * 模式由 verificationMode 控制(默认 \"auto\"):\r\n * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。\r\n * - introspection:强制 Introspection(未配置端点时回退本地)。\r\n * - local:仅本地验签(不感知撤销)。\r\n * - hybrid:本地优先,失败后 Introspection(旧版行为)。\r\n *\r\n * @returns token payload 或者 null(验证失败)\r\n */\r\n async verify(rawToken: string): Promise<VerifiedTokenPayload | null> {\r\n const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));\r\n const runIntrospection = async () =>\r\n rejectM2mPayload(await verifyViaIntrospection(rawToken));\r\n\r\n if (verificationMode === \"local\") {\r\n return await runLocal();\r\n }\r\n if (verificationMode === \"introspection\") {\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n }\r\n if (verificationMode === \"hybrid\") {\r\n const local = await runLocal();\r\n if (local) return local;\r\n return hasIntrospection ? await runIntrospection() : null;\r\n }\r\n // auto:Introspection 配置存在时默认走 Introspection,避免本地验签绕过撤销。\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n },\r\n\r\n /**\r\n * 直接调用 Introspection 端点\r\n */\r\n introspect,\r\n\r\n /**\r\n * 清除指定 token 的 Introspection 缓存\r\n */\r\n invalidateCache(token: string): void {\r\n introspectCache.delete(token);\r\n },\r\n\r\n /**\r\n * 验证 Logout Token(Back-Channel Logout)\r\n *\r\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\r\n * 验证要求(OIDC Back-Channel Logout 1.0):\r\n * 1. type === \"logout_token\"\r\n * 2. iss 匹配配置的 issuer\r\n * 3. aud 包含当前 client_id\r\n * 4. exp 存在(规范要求)\r\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\r\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\r\n *\r\n * @returns LogoutTokenPayload 或 null(验证失败)\r\n */\r\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\r\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\r\n const validateLogoutPayload = async (\r\n payload: Record<string, unknown>\r\n ): Promise<LogoutTokenPayload | null> => {\r\n if ((payload as { type?: string }).type !== \"logout_token\") {\r\n return null;\r\n }\r\n // 规范要求 logout_token 必须携带 exp / iat,且不得携带 nonce\r\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\r\n return null;\r\n }\r\n if (typeof (payload as { iat?: unknown }).iat !== \"number\") {\r\n return null;\r\n }\r\n if ((payload as { nonce?: unknown }).nonce !== undefined) {\r\n return null;\r\n }\r\n const events = (payload as { events?: Record<string, unknown> }).events;\r\n const logoutEvent =\r\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\r\n // 事件值必须是对象(通常为空对象 {})\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n return null;\r\n }\r\n const jti = (payload as { jti?: string }).jti;\r\n if (!jti || typeof jti !== \"string\") {\r\n return null;\r\n }\r\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\r\n // 否则无法定位要终止的会话/用户,按验证失败处理\r\n const sub = (payload as { sub?: unknown }).sub;\r\n const sid = (payload as { sid?: unknown }).sid;\r\n const hasSub = typeof sub === \"string\" && sub.length > 0;\r\n const hasSid = typeof sid === \"string\" && sid.length > 0;\r\n if (!hasSub && !hasSid) {\r\n return null;\r\n }\r\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\r\n const iss = (payload as { iss?: string }).iss || issuer;\r\n const jtiKey = `${iss}:${jti}`;\r\n if (logoutJtiStore) {\r\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\r\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\r\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\r\n if (!firstUse) return null;\r\n } else {\r\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\r\n // 多实例部署请补充 addIfAbsent 实现\r\n if (await logoutJtiStore.has(jtiKey)) {\r\n return null;\r\n }\r\n await logoutJtiStore.add(jtiKey, 10 * 60);\r\n }\r\n } else {\r\n if (processedLogoutJtis.has(jtiKey)) {\r\n return null;\r\n }\r\n processedLogoutJtis.set(jtiKey, Date.now());\r\n }\r\n return payload as unknown as LogoutTokenPayload;\r\n };\r\n\r\n // 按 JWT header alg 分发,避免跨算法静默回退\r\n let alg: string | undefined;\r\n try {\r\n alg = decodeProtectedHeader(token).alg;\r\n } catch {\r\n return null;\r\n }\r\n\r\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\r\n // 直接公钥验证失败(如密钥轮换后本地公钥过期)时回退 JWKS;\r\n // 无任何可用公钥时直接失败,不回退 HS256。\r\n if (alg === \"RS256\") {\r\n const directKey = await getLogoutRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\r\n // 与 verify() 一致按验证失败处理,而不是异常穿透\r\n let jwks: ReturnType<typeof getJwksKeySet> = null;\r\n try {\r\n jwks = getJwksKeySet();\r\n } catch {\r\n return null;\r\n }\r\n if (jwks) {\r\n try {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n // 2. HS256:使用 logoutTokenSecret(专用密钥);回退 accessTokenSecret 默认关闭\r\n if (alg === \"HS256\") {\r\n const secret =\r\n logoutTokenSecret ||\r\n (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : undefined);\r\n if (!secret) return null;\r\n if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {\r\n if (!warnedLegacyLogoutSecret) {\r\n warnedLegacyLogoutSecret = true;\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n }\r\n\r\n try {\r\n const key = new TextEncoder().encode(secret);\r\n const { payload } = await jwtVerify(token, key, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n },\r\n };\r\n}\r\n\r\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\r\nexport interface SsoMiddlewareRequest {\r\n headers?: Record<string, string | undefined>;\r\n /** 验证通过后由中间件挂载的用户信息 */\r\n user?: VerifiedTokenPayload;\r\n [key: string]: unknown;\r\n}\r\n\r\n/** 框架无关的最小中间件响应类型 */\r\nexport interface SsoMiddlewareResponse {\r\n status?: (code: number) => { json: (body: unknown) => unknown };\r\n [key: string]: unknown;\r\n}\r\n\r\n/**\r\n * Express/Next.js 兼容中间件\r\n *\r\n * 使用示例(Express):\r\n * ```typescript\r\n * app.use(ssoMiddleware({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"SECRET\",\r\n * audience: \"advisor\",\r\n * }));\r\n * ```\r\n *\r\n * 使用示例(Next.js Route Handler):\r\n * ```typescript\r\n * const verifier = createTokenVerifier({ ... });\r\n * export async function GET(request: NextRequest) {\r\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\r\n * const payload = token ? await verifier.verify(token) : null;\r\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\r\n * // ...\r\n * }\r\n * ```\r\n */\r\nexport function ssoMiddleware(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\r\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\r\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\r\n\r\n if (!token) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\r\n return;\r\n }\r\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\r\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\r\n return;\r\n }\r\n\r\n const payload = await verifier.verify(token);\r\n if (!payload) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\r\n return;\r\n }\r\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\r\n return;\r\n }\r\n\r\n // 挂载用户信息到请求对象\r\n req.user = payload;\r\n next();\r\n };\r\n}\r\n\r\n/**\r\n * 创建 Logout Token 专用验证器\r\n *\r\n * 适用于仅需处理 backchannel logout 的子项目。\r\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\r\n *\r\n * 使用示例:\r\n * ```typescript\r\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\r\n *\r\n * const logoutVerifier = createLogoutTokenVerifier({\r\n * audience: \"advisor\",\r\n * issuer: \"https://nihplod.cn\",\r\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\r\n * });\r\n *\r\n * // 在 backchannel logout 端点中:\r\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\r\n * const token = req.body.logout_token;\r\n * const payload = await logoutVerifier.verify(token);\r\n * if (payload) {\r\n * await clearUserSession(payload.sub);\r\n * res.status(200).end();\r\n * } else {\r\n * res.status(400).end();\r\n * }\r\n * });\r\n * ```\r\n */\r\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return {\r\n /**\r\n * 验证 logout_token\r\n */\r\n async verify(token: string): Promise<LogoutTokenPayload | null> {\r\n return verifier.verifyLogoutToken(token);\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
package/dist/index.mjs CHANGED
@@ -37,7 +37,9 @@ function createTokenVerifier(options) {
37
37
  introspectRetries = 1,
38
38
  clockToleranceSeconds = 60,
39
39
  logoutJtiStore,
40
- strictAudience = true
40
+ strictAudience = true,
41
+ verificationMode = "auto",
42
+ allowM2mTokens = false
41
43
  } = options;
42
44
  let warnedLegacyLogoutSecret = false;
43
45
  if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {
@@ -165,7 +167,9 @@ function createTokenVerifier(options) {
165
167
  return null;
166
168
  }
167
169
  if (!response.ok) {
168
- if (response.status >= 500 && attempt < introspectRetries) continue;
170
+ if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {
171
+ continue;
172
+ }
169
173
  return null;
170
174
  }
171
175
  try {
@@ -201,7 +205,11 @@ function createTokenVerifier(options) {
201
205
  iss: issuer,
202
206
  client_id: data.client_id,
203
207
  scope: data.scope,
204
- exp: data.exp
208
+ exp: data.exp,
209
+ ...data.client_type ? { client_type: data.client_type } : {},
210
+ ...data.sid ? { sid: data.sid } : {},
211
+ ...typeof data.auth_time === "number" ? { auth_time: data.auth_time } : {},
212
+ ...data.cnf ? { cnf: data.cnf } : {}
205
213
  };
206
214
  }
207
215
  let ttl = introspectCacheTtl;
@@ -276,34 +284,63 @@ function createTokenVerifier(options) {
276
284
  return null;
277
285
  }
278
286
  }
287
+ const hasIntrospection = Boolean(introspectionEndpoint && clientId);
288
+ const rejectM2mPayload = (payload) => {
289
+ if (!payload || allowM2mTokens) return payload;
290
+ const isM2m = payload.client_type === "m2m" || payload.sub.startsWith("client:");
291
+ return isM2m ? null : payload;
292
+ };
293
+ const verifyLocallyBoth = async (rawToken) => {
294
+ if (accessTokenSecret) {
295
+ const local = await verifyLocally(rawToken);
296
+ if (local) return local;
297
+ }
298
+ return verifyWithRS256(rawToken);
299
+ };
300
+ const verifyViaIntrospection = async (rawToken) => {
301
+ const result = await introspect(rawToken);
302
+ if (result?.active !== true || !result.sub) return null;
303
+ return {
304
+ sub: result.sub,
305
+ aud: audience,
306
+ iss: issuer,
307
+ client_id: result.client_id,
308
+ scope: result.scope,
309
+ exp: result.exp,
310
+ ...result.client_type ? { client_type: result.client_type } : {},
311
+ ...result.sid ? { sid: result.sid } : {},
312
+ ...typeof result.auth_time === "number" ? { auth_time: result.auth_time } : {},
313
+ ...result.cnf ? { cnf: result.cnf } : {},
314
+ ...result.token_type ? { token_type: result.token_type } : {}
315
+ };
316
+ };
279
317
  return {
280
318
  /**
281
319
  * 验证 Access Token
282
320
  *
283
- * 验证顺序:
284
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
285
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
286
- * 3. 若上述均失败/未配置,调用 Introspection 端点
321
+ * 模式由 verificationMode 控制(默认 "auto"):
322
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
323
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
324
+ * - local:仅本地验签(不感知撤销)。
325
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
287
326
  *
288
327
  * @returns token payload 或者 null(验证失败)
289
328
  */
290
- async verify(token) {
291
- if (accessTokenSecret) {
292
- const local = await verifyLocally(token);
329
+ async verify(rawToken) {
330
+ const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));
331
+ const runIntrospection = async () => rejectM2mPayload(await verifyViaIntrospection(rawToken));
332
+ if (verificationMode === "local") {
333
+ return await runLocal();
334
+ }
335
+ if (verificationMode === "introspection") {
336
+ return hasIntrospection ? await runIntrospection() : await runLocal();
337
+ }
338
+ if (verificationMode === "hybrid") {
339
+ const local = await runLocal();
293
340
  if (local) return local;
341
+ return hasIntrospection ? await runIntrospection() : null;
294
342
  }
295
- const rs256Result = await verifyWithRS256(token);
296
- if (rs256Result) return rs256Result;
297
- const result = await introspect(token);
298
- if (result?.active !== true || !result.sub) return null;
299
- return {
300
- sub: result.sub,
301
- aud: audience,
302
- iss: issuer,
303
- client_id: result.client_id,
304
- scope: result.scope,
305
- exp: result.exp
306
- };
343
+ return hasIntrospection ? await runIntrospection() : await runLocal();
307
344
  },
308
345
  /**
309
346
  * 直接调用 Introspection 端点
@@ -337,6 +374,12 @@ function createTokenVerifier(options) {
337
374
  if (typeof payload.exp !== "number") {
338
375
  return null;
339
376
  }
377
+ if (typeof payload.iat !== "number") {
378
+ return null;
379
+ }
380
+ if (payload.nonce !== void 0) {
381
+ return null;
382
+ }
340
383
  const events = payload.events;
341
384
  const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
342
385
  if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-verify\r\n *\r\n * NIHPLOD 一网通 SSO Token 验证工具包\r\n *\r\n * 供子项目在后端验证主站签发的 Access Token。\r\n *\r\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\r\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\r\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\r\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\r\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\r\n *\r\n * 安装:npm install @nihplod/sso-verify\r\n *\r\n * 使用:\r\n * ```typescript\r\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\r\n * const verifier = createTokenVerifier({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"YOUR_CLIENT_SECRET\",\r\n * audience: \"advisor\",\r\n * });\r\n *\r\n * const payload = await verifier.verify(token);\r\n * if (payload) {\r\n * console.log(payload.sub); // 用户 ID\r\n * }\r\n * ```\r\n */\r\n\r\nimport {\r\n jwtVerify,\r\n importSPKI,\r\n createRemoteJWKSet,\r\n decodeProtectedHeader,\r\n type KeyLike,\r\n type JWTPayload,\r\n} from \"jose\";\r\nimport { LRUCache } from \"lru-cache\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoVerifierOptions {\r\n /** 期望的 audience(对应子项目的 client_id) */\r\n audience: string;\r\n\r\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\r\n issuer?: string;\r\n\r\n /**\r\n * Token Introspection 端点 URL。\r\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\r\n */\r\n introspectionEndpoint?: string;\r\n\r\n /** 子项目的 client_id(用于 introspection) */\r\n clientId?: string;\r\n\r\n /** 子项目的 client_secret(用于 introspection) */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 共享的 Access Token Secret(可选)。\r\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\r\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\r\n */\r\n accessTokenSecret?: string;\r\n\r\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\r\n introspectCacheTtl?: number;\r\n\r\n /**\r\n * Introspection 请求超时时间(毫秒),默认 10 秒。\r\n * 超时按验证失败处理(返回 null)。\r\n */\r\n introspectTimeoutMs?: number;\r\n\r\n /**\r\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\r\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\r\n * 设为 0 表示不缓存 active:false 结果。\r\n */\r\n introspectNegativeCacheTtl?: number;\r\n\r\n /**\r\n * Access Token RS256 公钥(PEM 格式,可选)。\r\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\r\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\r\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\r\n * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS\r\n * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。\r\n */\r\n accessTokenPublicKey?: string;\r\n\r\n /**\r\n * JWKS 端点 URL(可选)。\r\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\r\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;\r\n * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。\r\n */\r\n jwksUri?: string;\r\n\r\n /**\r\n * Logout Token Secret(可选)。\r\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\r\n * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token\r\n * 验证将失败(返回 null)。\r\n */\r\n logoutTokenSecret?: string;\r\n\r\n /**\r\n * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。\r\n *\r\n * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。\r\n * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),\r\n * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。\r\n * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。\r\n *\r\n * 开启后会在两处输出告警(console.warn):\r\n * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置\r\n * logoutTokenSecret)立即提示一次;\r\n * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发\r\n * (一次性告警)。\r\n */\r\n allowLegacyAccessTokenSecretFallback?: boolean;\r\n\r\n /**\r\n * Logout Token RS256 公钥(PEM 格式,可选)。\r\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\r\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\r\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;\r\n * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。\r\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\r\n * 不会静默回退到 HS256。\r\n */\r\n logoutTokenPublicKey?: string;\r\n\r\n /**\r\n * Introspection 请求失败重试次数,默认 1。\r\n * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。\r\n * 设为 0 表示不重试。\r\n */\r\n introspectRetries?: number;\r\n\r\n /**\r\n * JWT 时钟偏移容忍(秒),默认 60。\r\n * 应用于所有本地验签路径(HS256/RS256/logout token),\r\n * 用于容忍子项目与主站之间的时钟偏差。\r\n */\r\n clockToleranceSeconds?: number;\r\n\r\n /**\r\n * Logout Token jti 外部存储(可选)。\r\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\r\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\r\n */\r\n logoutJtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * Introspection audience 严格模式(默认 true,fail-closed)。\r\n *\r\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\r\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\r\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\r\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\r\n */\r\n strictAudience?: boolean;\r\n}\r\n\r\n/**\r\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\r\n *\r\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\r\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\r\n */\r\nexport interface LogoutJtiStore {\r\n /** 判断 jti 是否已处理过 */\r\n has(key: string): boolean | Promise<boolean>;\r\n /** 记录已处理的 jti,ttlSeconds 后过期 */\r\n add(key: string, ttlSeconds: number): void | Promise<void>;\r\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\r\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\r\n}\r\n\r\nexport interface VerifiedTokenPayload extends JWTPayload {\r\n sub: string;\r\n aud: string;\r\n iss: string;\r\n client_id?: string;\r\n scope?: string;\r\n phone?: string;\r\n}\r\n\r\n/**\r\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\r\n *\r\n * 主站签发 logout_token 时包含以下字段:\r\n * - iss: 主站 issuer\r\n * - aud: 目标 client_id\r\n * - sub: 用户 ID(与 sid 至少其一)\r\n * - iat: 签发时间\r\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\r\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\r\n */\r\nexport interface LogoutTokenPayload {\r\n /** JWT type,logout_token 固定值为 \"logout_token\" */\r\n type: \"logout_token\";\r\n /** 签发者 */\r\n iss: string;\r\n /** 目标 audience */\r\n aud: string;\r\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\r\n sub?: string;\r\n /** 签发时间(UNIX timestamp) */\r\n iat: number;\r\n /** JWT ID(唯一标识,防重放) */\r\n jti: string;\r\n /** Backchannel logout 事件声明 */\r\n events: {\r\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\r\n };\r\n /** sid (Session ID),可选 */\r\n sid?: string;\r\n}\r\n\r\ninterface IntrospectResponse {\r\n active: boolean;\r\n sub?: string;\r\n aud?: string | string[];\r\n client_id?: string;\r\n scope?: string;\r\n exp?: number;\r\n [key: string]: unknown;\r\n}\r\n\r\n// ============================================\r\n// Introspection 结果缓存\r\n// ============================================\r\n\r\ninterface IntrospectCacheEntry {\r\n active: boolean;\r\n payload: VerifiedTokenPayload | null;\r\n}\r\n\r\nfunction createIntrospectCache(ttlMs: number) {\r\n return new LRUCache<string, IntrospectCacheEntry>({\r\n max: 10000,\r\n ttl: ttlMs,\r\n });\r\n}\r\n\r\n// ============================================\r\n// Logout Token jti 重放防护缓存\r\n// ============================================\r\n\r\n/**\r\n * 已处理的 logout_token jti 缓存。\r\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\r\n */\r\nconst processedLogoutJtis = new LRUCache<string, number>({\r\n max: 10000,\r\n ttl: 10 * 60 * 1000,\r\n});\r\n\r\n// 废弃的 HS256 logout_token 回退(accessTokenSecret 代验)告警文案,\r\n// 构造时与首次实际使用时复用同一条消息\r\nconst LEGACY_LOGOUT_SECRET_FALLBACK_WARNING =\r\n \"[sso-verify] 正在使用 accessTokenSecret 验证 HS256 logout_token(废弃路径):\" +\r\n \"共享 access secret 的服务可伪造任意用户登出。\" +\r\n \"请改用 logoutTokenSecret,或配置 logoutTokenPublicKey/jwksUri 走 RS256。\";\r\n\r\n// 非生产环境下 http:// 非 loopback 端点的告警去重(每个端点名每进程只提示一次)\r\nconst warnedInsecureEndpoints = new Set<string>();\r\n\r\n// ============================================\r\n// Token Verifier\r\n// ============================================\r\n\r\nexport function createTokenVerifier(options: SsoVerifierOptions) {\r\n const {\r\n audience,\r\n issuer = \"https://nihplod.cn\",\r\n introspectionEndpoint,\r\n clientId,\r\n clientSecret,\r\n accessTokenSecret,\r\n accessTokenPublicKey,\r\n jwksUri,\r\n logoutTokenSecret,\r\n logoutTokenPublicKey,\r\n allowLegacyAccessTokenSecretFallback = false,\r\n introspectCacheTtl = 30 * 1000,\r\n introspectTimeoutMs = 10 * 1000,\r\n introspectNegativeCacheTtl = 5 * 1000,\r\n introspectRetries = 1,\r\n clockToleranceSeconds = 60,\r\n logoutJtiStore,\r\n strictAudience = true,\r\n } = options;\r\n\r\n // 一次性告警标志:使用废弃的 accessTokenSecret 回退验证 logout_token 时提示迁移\r\n let warnedLegacyLogoutSecret = false;\r\n\r\n // 构造时即提示:显式开启回退且该路径实际可生效(有 accessTokenSecret、无 logoutTokenSecret)\r\n if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n\r\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\r\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\r\n // 非生产环境下 http:// 非 loopback 端点输出一次性告警(不阻断本地联调)。\r\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\r\n if (!value) return;\r\n let url: URL;\r\n try {\r\n url = new URL(value);\r\n } catch {\r\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\r\n }\r\n if (url.protocol === \"https:\") return;\r\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\r\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\r\n if (url.protocol === \"http:\" && isLoopback) return;\r\n if (process.env.NODE_ENV !== \"production\") {\r\n if (url.protocol === \"http:\" && !warnedInsecureEndpoints.has(name)) {\r\n warnedInsecureEndpoints.add(name);\r\n console.warn(\r\n `[sso-verify] ${name} 使用非加密的 http:// 且非 loopback 地址;` +\r\n \"生产环境(NODE_ENV=production)将直接抛错\"\r\n );\r\n }\r\n return;\r\n }\r\n throw new Error(\r\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\r\n \"避免凭证/令牌经明文传输\"\r\n );\r\n };\r\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\r\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\r\n\r\n const introspectCache = createIntrospectCache(introspectCacheTtl);\r\n\r\n // RS256 JWKS 远程密钥集(延迟初始化)\r\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\r\n function getJwksKeySet() {\r\n if (!jwksUri) return null;\r\n if (!_jwksKeySet) {\r\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\r\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\r\n });\r\n }\r\n return _jwksKeySet;\r\n }\r\n\r\n // RS256 公钥(延迟导入)\r\n let _rs256PublicKey: KeyLike | null = null;\r\n let _rs256KeyInitialized = false;\r\n async function getRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_rs256KeyInitialized) {\r\n _rs256KeyInitialized = true;\r\n if (!accessTokenPublicKey) {\r\n _rs256PublicKey = null;\r\n } else {\r\n try {\r\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\r\n } catch {\r\n _rs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _rs256PublicKey;\r\n }\r\n\r\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\r\n let _logoutRs256PublicKey: KeyLike | null = null;\r\n let _logoutRs256KeyInitialized = false;\r\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_logoutRs256KeyInitialized) {\r\n _logoutRs256KeyInitialized = true;\r\n if (!logoutTokenPublicKey) {\r\n _logoutRs256PublicKey = null;\r\n } else {\r\n try {\r\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\r\n } catch {\r\n _logoutRs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _logoutRs256PublicKey;\r\n }\r\n\r\n /**\r\n * Introspection 响应的 aud 归属校验(防 confused deputy):\r\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\r\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\r\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\r\n * 默认 strictAudience=true 时 fail-closed 拒绝。\r\n */\r\n function matchesAudience(data: IntrospectResponse): boolean {\r\n const aud = data.aud;\r\n if (typeof aud === \"string\") return aud === audience;\r\n if (Array.isArray(aud)) return aud.includes(audience);\r\n if (typeof data.client_id === \"string\") return data.client_id === audience;\r\n return !strictAudience;\r\n }\r\n\r\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\r\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\r\n\r\n /**\r\n * 调用主站 Introspection 端点验证 token\r\n *\r\n * Public Client 可省略 client_secret,仅传 client_id。\r\n * Confidential Client 建议传入 client_secret 以通过端点认证。\r\n */\r\n async function introspect(token: string): Promise<IntrospectResponse | null> {\r\n if (!introspectionEndpoint || !clientId) {\r\n return null;\r\n }\r\n\r\n const cached = introspectCache.get(token);\r\n if (cached) {\r\n return { active: cached.active, ...(cached.payload || {}) };\r\n }\r\n\r\n const inflight = inflightIntrospects.get(token);\r\n if (inflight) {\r\n return inflight;\r\n }\r\n\r\n const promise = doIntrospect(token).finally(() => {\r\n inflightIntrospects.delete(token);\r\n });\r\n inflightIntrospects.set(token, promise);\r\n return promise;\r\n }\r\n\r\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\r\n const params = new URLSearchParams({ token, client_id: clientId! });\r\n if (clientSecret) {\r\n params.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\r\n let data: IntrospectResponse | null = null;\r\n for (let attempt = 0; ; attempt++) {\r\n if (attempt > 0) {\r\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\r\n }\r\n let response: Response;\r\n try {\r\n response = await fetch(introspectionEndpoint!, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: params,\r\n signal: AbortSignal.timeout(introspectTimeoutMs),\r\n });\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n if (!response.ok) {\r\n if (response.status >= 500 && attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\r\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\r\n try {\r\n data = (await response.json()) as IntrospectResponse;\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n break;\r\n }\r\n if (!data) return null;\r\n\r\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\r\n if (data.active !== true) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\r\n if (!matchesAudience(data)) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n let payload: VerifiedTokenPayload | null = null;\r\n if (data.sub) {\r\n payload = {\r\n sub: data.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: data.client_id,\r\n scope: data.scope,\r\n exp: data.exp,\r\n } as VerifiedTokenPayload;\r\n }\r\n\r\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\r\n // 已过期/无剩余有效期时不缓存。\r\n let ttl = introspectCacheTtl;\r\n if (typeof data.exp === \"number\") {\r\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\r\n }\r\n if (ttl > 0) {\r\n introspectCache.set(token, { active: true, payload }, { ttl });\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\r\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\r\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\r\n * (session-init / getSessionUser 等)一律读 payload.sub。\r\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\r\n */\r\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\r\n const p = payload as VerifiedTokenPayload & { id?: string };\r\n if (!p.sub && typeof p.id === \"string\" && p.id) {\r\n p.sub = p.id;\r\n }\r\n return p.sub ? p : null;\r\n }\r\n\r\n /**\r\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\r\n */\r\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\r\n if (!accessTokenSecret) return null;\r\n\r\n try {\r\n const secret = new TextEncoder().encode(accessTokenSecret);\r\n const { payload } = await jwtVerify(token, secret, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n\r\n if ((payload as { type?: string }).type !== \"access_token\") {\r\n return null;\r\n }\r\n\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * RS256 本地验证(优先直接公钥,验证失败回退 JWKS 远程获取)\r\n */\r\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 检查是否有 RS256 验证能力\r\n const hasPublicKey = Boolean(accessTokenPublicKey);\r\n const hasJwks = Boolean(jwksUri);\r\n if (!hasPublicKey && !hasJwks) return null;\r\n\r\n try {\r\n // 优先使用直接公钥;验证失败(如主站轮换密钥后本地公钥过期)\r\n // 时回退 JWKS,而不是直接判定失败\r\n const directKey = await getRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // 回退到 JWKS 远程获取\r\n const jwks = getJwksKeySet();\r\n if (jwks) {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n }\r\n\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return {\r\n /**\r\n * 验证 Access Token\r\n *\r\n * 验证顺序:\r\n * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证\r\n * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证\r\n * 3. 若上述均失败/未配置,调用 Introspection 端点\r\n *\r\n * @returns token payload 或者 null(验证失败)\r\n */\r\n async verify(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 1. HS256 本地验证(优先速度,适用于共享 secret 的内部服务)\r\n if (accessTokenSecret) {\r\n const local = await verifyLocally(token);\r\n if (local) return local;\r\n }\r\n\r\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\r\n const rs256Result = await verifyWithRS256(token);\r\n if (rs256Result) return rs256Result;\r\n\r\n // 3. Introspection 验证(兜底方案)\r\n const result = await introspect(token);\r\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\r\n if (result?.active !== true || !result.sub) return null;\r\n\r\n return {\r\n sub: result.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: result.client_id,\r\n scope: result.scope,\r\n exp: result.exp,\r\n } as VerifiedTokenPayload;\r\n },\r\n\r\n /**\r\n * 直接调用 Introspection 端点\r\n */\r\n introspect,\r\n\r\n /**\r\n * 清除指定 token 的 Introspection 缓存\r\n */\r\n invalidateCache(token: string): void {\r\n introspectCache.delete(token);\r\n },\r\n\r\n /**\r\n * 验证 Logout Token(Back-Channel Logout)\r\n *\r\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\r\n * 验证要求(OIDC Back-Channel Logout 1.0):\r\n * 1. type === \"logout_token\"\r\n * 2. iss 匹配配置的 issuer\r\n * 3. aud 包含当前 client_id\r\n * 4. exp 存在(规范要求)\r\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\r\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\r\n *\r\n * @returns LogoutTokenPayload 或 null(验证失败)\r\n */\r\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\r\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\r\n const validateLogoutPayload = async (\r\n payload: Record<string, unknown>\r\n ): Promise<LogoutTokenPayload | null> => {\r\n if ((payload as { type?: string }).type !== \"logout_token\") {\r\n return null;\r\n }\r\n // 规范要求 logout_token 必须携带 exp\r\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\r\n return null;\r\n }\r\n const events = (payload as { events?: Record<string, unknown> }).events;\r\n const logoutEvent =\r\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\r\n // 事件值必须是对象(通常为空对象 {})\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n return null;\r\n }\r\n const jti = (payload as { jti?: string }).jti;\r\n if (!jti || typeof jti !== \"string\") {\r\n return null;\r\n }\r\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\r\n // 否则无法定位要终止的会话/用户,按验证失败处理\r\n const sub = (payload as { sub?: unknown }).sub;\r\n const sid = (payload as { sid?: unknown }).sid;\r\n const hasSub = typeof sub === \"string\" && sub.length > 0;\r\n const hasSid = typeof sid === \"string\" && sid.length > 0;\r\n if (!hasSub && !hasSid) {\r\n return null;\r\n }\r\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\r\n const iss = (payload as { iss?: string }).iss || issuer;\r\n const jtiKey = `${iss}:${jti}`;\r\n if (logoutJtiStore) {\r\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\r\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\r\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\r\n if (!firstUse) return null;\r\n } else {\r\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\r\n // 多实例部署请补充 addIfAbsent 实现\r\n if (await logoutJtiStore.has(jtiKey)) {\r\n return null;\r\n }\r\n await logoutJtiStore.add(jtiKey, 10 * 60);\r\n }\r\n } else {\r\n if (processedLogoutJtis.has(jtiKey)) {\r\n return null;\r\n }\r\n processedLogoutJtis.set(jtiKey, Date.now());\r\n }\r\n return payload as unknown as LogoutTokenPayload;\r\n };\r\n\r\n // 按 JWT header alg 分发,避免跨算法静默回退\r\n let alg: string | undefined;\r\n try {\r\n alg = decodeProtectedHeader(token).alg;\r\n } catch {\r\n return null;\r\n }\r\n\r\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\r\n // 直接公钥验证失败(如密钥轮换后本地公钥过期)时回退 JWKS;\r\n // 无任何可用公钥时直接失败,不回退 HS256。\r\n if (alg === \"RS256\") {\r\n const directKey = await getLogoutRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\r\n // 与 verify() 一致按验证失败处理,而不是异常穿透\r\n let jwks: ReturnType<typeof getJwksKeySet> = null;\r\n try {\r\n jwks = getJwksKeySet();\r\n } catch {\r\n return null;\r\n }\r\n if (jwks) {\r\n try {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n // 2. HS256:使用 logoutTokenSecret(专用密钥);回退 accessTokenSecret 默认关闭\r\n if (alg === \"HS256\") {\r\n const secret =\r\n logoutTokenSecret ||\r\n (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : undefined);\r\n if (!secret) return null;\r\n if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {\r\n if (!warnedLegacyLogoutSecret) {\r\n warnedLegacyLogoutSecret = true;\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n }\r\n\r\n try {\r\n const key = new TextEncoder().encode(secret);\r\n const { payload } = await jwtVerify(token, key, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n },\r\n };\r\n}\r\n\r\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\r\nexport interface SsoMiddlewareRequest {\r\n headers?: Record<string, string | undefined>;\r\n /** 验证通过后由中间件挂载的用户信息 */\r\n user?: VerifiedTokenPayload;\r\n [key: string]: unknown;\r\n}\r\n\r\n/** 框架无关的最小中间件响应类型 */\r\nexport interface SsoMiddlewareResponse {\r\n status?: (code: number) => { json: (body: unknown) => unknown };\r\n [key: string]: unknown;\r\n}\r\n\r\n/**\r\n * Express/Next.js 兼容中间件\r\n *\r\n * 使用示例(Express):\r\n * ```typescript\r\n * app.use(ssoMiddleware({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"SECRET\",\r\n * audience: \"advisor\",\r\n * }));\r\n * ```\r\n *\r\n * 使用示例(Next.js Route Handler):\r\n * ```typescript\r\n * const verifier = createTokenVerifier({ ... });\r\n * export async function GET(request: NextRequest) {\r\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\r\n * const payload = token ? await verifier.verify(token) : null;\r\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\r\n * // ...\r\n * }\r\n * ```\r\n */\r\nexport function ssoMiddleware(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\r\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\r\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\r\n\r\n if (!token) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\r\n return;\r\n }\r\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\r\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\r\n return;\r\n }\r\n\r\n const payload = await verifier.verify(token);\r\n if (!payload) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\r\n return;\r\n }\r\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\r\n return;\r\n }\r\n\r\n // 挂载用户信息到请求对象\r\n req.user = payload;\r\n next();\r\n };\r\n}\r\n\r\n/**\r\n * 创建 Logout Token 专用验证器\r\n *\r\n * 适用于仅需处理 backchannel logout 的子项目。\r\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\r\n *\r\n * 使用示例:\r\n * ```typescript\r\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\r\n *\r\n * const logoutVerifier = createLogoutTokenVerifier({\r\n * audience: \"advisor\",\r\n * issuer: \"https://nihplod.cn\",\r\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\r\n * });\r\n *\r\n * // 在 backchannel logout 端点中:\r\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\r\n * const token = req.body.logout_token;\r\n * const payload = await logoutVerifier.verify(token);\r\n * if (payload) {\r\n * await clearUserSession(payload.sub);\r\n * res.status(200).end();\r\n * } else {\r\n * res.status(400).end();\r\n * }\r\n * });\r\n * ```\r\n */\r\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return {\r\n /**\r\n * 验证 logout_token\r\n */\r\n async verify(token: string): Promise<LogoutTokenPayload | null> {\r\n return verifier.verifyLogoutToken(token);\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-verify\r\n *\r\n * NIHPLOD 一网通 SSO Token 验证工具包\r\n *\r\n * 供子项目在后端验证主站签发的 Access Token。\r\n *\r\n * 由于主站 access_token 的签名算法取决于部署配置(配置 JWT_ACCESS_PRIVATE_KEY\r\n * 后以 RS256 签名,未配置时回退 HS256 对称签名),JWKS 端点仅在 RS256\r\n * 模式下公开公钥,因此本工具包默认采用 OAuth 2.0 Token Introspection\r\n * (RFC 7662)端点进行验证。对于已知共享密钥/公钥的内部服务,也可传入\r\n * accessTokenSecret(HS256)或 accessTokenPublicKey / jwksUri(RS256)做本地 JWT 验证。\r\n *\r\n * 安装:npm install @nihplod/sso-verify\r\n *\r\n * 使用:\r\n * ```typescript\r\n * import { createTokenVerifier } from \"@nihplod/sso-verify\";\r\n * const verifier = createTokenVerifier({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"YOUR_CLIENT_SECRET\",\r\n * audience: \"advisor\",\r\n * });\r\n *\r\n * const payload = await verifier.verify(token);\r\n * if (payload) {\r\n * console.log(payload.sub); // 用户 ID\r\n * }\r\n * ```\r\n */\r\n\r\nimport {\r\n jwtVerify,\r\n importSPKI,\r\n createRemoteJWKSet,\r\n decodeProtectedHeader,\r\n type KeyLike,\r\n type JWTPayload,\r\n} from \"jose\";\r\nimport { LRUCache } from \"lru-cache\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoVerifierOptions {\r\n /** 期望的 audience(对应子项目的 client_id) */\r\n audience: string;\r\n\r\n /** 期望的 issuer(主站 URL),默认 \"https://nihplod.cn\" */\r\n issuer?: string;\r\n\r\n /**\r\n * Token Introspection 端点 URL。\r\n * 推荐优先使用,与主站签名算法(HS256/RS256)无关,且能实时感知撤销。\r\n */\r\n introspectionEndpoint?: string;\r\n\r\n /** 子项目的 client_id(用于 introspection) */\r\n clientId?: string;\r\n\r\n /** 子项目的 client_secret(用于 introspection) */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 共享的 Access Token Secret(可选)。\r\n * 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。\r\n * 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。\r\n */\r\n accessTokenSecret?: string;\r\n\r\n /** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */\r\n introspectCacheTtl?: number;\r\n\r\n /**\r\n * Introspection 请求超时时间(毫秒),默认 10 秒。\r\n * 超时按验证失败处理(返回 null)。\r\n */\r\n introspectTimeoutMs?: number;\r\n\r\n /**\r\n * Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。\r\n * 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。\r\n * 设为 0 表示不缓存 active:false 结果。\r\n */\r\n introspectNegativeCacheTtl?: number;\r\n\r\n /**\r\n * Access Token RS256 公钥(PEM 格式,可选)。\r\n * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,\r\n * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。\r\n * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。\r\n * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS\r\n * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。\r\n */\r\n accessTokenPublicKey?: string;\r\n\r\n /**\r\n * JWKS 端点 URL(可选)。\r\n * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。\r\n * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;\r\n * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。\r\n */\r\n jwksUri?: string;\r\n\r\n /**\r\n * Logout Token Secret(可选)。\r\n * 用于本地验证主站签发的 logout_token(HS256 签名)。\r\n * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token\r\n * 验证将失败(返回 null)。\r\n */\r\n logoutTokenSecret?: string;\r\n\r\n /**\r\n * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。\r\n *\r\n * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。\r\n * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),\r\n * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。\r\n * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。\r\n *\r\n * 开启后会在两处输出告警(console.warn):\r\n * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置\r\n * logoutTokenSecret)立即提示一次;\r\n * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发\r\n * (一次性告警)。\r\n */\r\n allowLegacyAccessTokenSecretFallback?: boolean;\r\n\r\n /**\r\n * Logout Token RS256 公钥(PEM 格式,可选)。\r\n * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,\r\n * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。\r\n * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;\r\n * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。\r\n * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),\r\n * 不会静默回退到 HS256。\r\n */\r\n logoutTokenPublicKey?: string;\r\n\r\n /**\r\n * Introspection 请求失败重试次数,默认 1。\r\n * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。\r\n * 设为 0 表示不重试。\r\n */\r\n introspectRetries?: number;\r\n\r\n /**\r\n * JWT 时钟偏移容忍(秒),默认 60。\r\n * 应用于所有本地验签路径(HS256/RS256/logout token),\r\n * 用于容忍子项目与主站之间的时钟偏差。\r\n */\r\n clockToleranceSeconds?: number;\r\n\r\n /**\r\n * Logout Token jti 外部存储(可选)。\r\n * 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);\r\n * 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。\r\n */\r\n logoutJtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * Introspection audience 严格模式(默认 true,fail-closed)。\r\n *\r\n * 严格模式下,introspection 响应既无 aud 又无 client_id 时拒绝\r\n * (防止 confused deputy:把发给其他 client 的 token 当成自己的)。\r\n * 主站 introspection 端点始终返回 client_id,默认开启不影响主站接入;\r\n * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。\r\n */\r\n strictAudience?: boolean;\r\n\r\n /**\r\n * 验证模式(默认 \"auto\"):\r\n * - \"auto\":配置了 introspectionEndpoint + clientId 时使用 Introspection(可实时感知撤销);\r\n * 未配置时回退本地 JWT 验签。\r\n * - \"introspection\":强制 Introspection;未配置端点时回退本地验签。\r\n * - \"local\":仅本地 HS256/RS256 验签(不感知撤销,仅建议非敏感、高吞吐场景)。\r\n * - \"hybrid\":先本地验签,失败后再 Introspection(旧版行为,性能优先)。\r\n *\r\n * 涉及账户状态/授权撤销的鉴权建议使用 \"auto\" 或 \"introspection\"。\r\n */\r\n verificationMode?: \"auto\" | \"introspection\" | \"local\" | \"hybrid\";\r\n\r\n /**\r\n * 是否允许 M2M(client_credentials)token 通过用户态校验(默认 false)。\r\n * M2M token 的 sub 为 `client:<client_id>`,不是用户 ID;若业务确实需要\r\n * 校验 M2M token,请显式设为 true 并自行区分 client_type。\r\n */\r\n allowM2mTokens?: boolean;\r\n}\r\n\r\n/**\r\n * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。\r\n *\r\n * 多实例部署应实现 addIfAbsent(原子 SET NX EX 语义)以保证并发/跨实例下\r\n * 同一 jti 只会被接受一次。未实现时回退 has + add(非原子,仅建议单实例使用)。\r\n */\r\nexport interface LogoutJtiStore {\r\n /** 判断 jti 是否已处理过 */\r\n has(key: string): boolean | Promise<boolean>;\r\n /** 记录已处理的 jti,ttlSeconds 后过期 */\r\n add(key: string, ttlSeconds: number): void | Promise<void>;\r\n /** 原子地\"不存在则记录\":返回 true 表示本次成功占用(首次使用),false 表示已存在 */\r\n addIfAbsent?(key: string, ttlSeconds: number): boolean | Promise<boolean>;\r\n}\r\n\r\nexport interface VerifiedTokenPayload extends JWTPayload {\r\n sub: string;\r\n aud: string;\r\n iss: string;\r\n client_id?: string;\r\n scope?: string;\r\n phone?: string;\r\n /** 身份类型:m2m = client_credentials,user = 用户授权码流程 */\r\n client_type?: \"m2m\" | \"user\";\r\n /** OAuthSession ID;携带 sid 的 token 可被会话级撤销 */\r\n sid?: string;\r\n /** 原始认证时间(Unix 秒),供 max_age 判断 */\r\n auth_time?: number;\r\n /** DPoP 绑定(RFC 9449 cnf.jkt) */\r\n cnf?: { jkt?: string };\r\n /** introspection 返回的 token 类型(Bearer / DPoP) */\r\n token_type?: \"Bearer\" | \"DPoP\";\r\n}\r\n\r\n/**\r\n * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)\r\n *\r\n * 主站签发 logout_token 时包含以下字段:\r\n * - iss: 主站 issuer\r\n * - aud: 目标 client_id\r\n * - sub: 用户 ID(与 sid 至少其一)\r\n * - iat: 签发时间\r\n * - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)\r\n * - events: { \"http://schemas.openid.net/event/backchannel-logout\": {} }\r\n */\r\nexport interface LogoutTokenPayload {\r\n /** JWT type,logout_token 固定值为 \"logout_token\" */\r\n type: \"logout_token\";\r\n /** 签发者 */\r\n iss: string;\r\n /** 目标 audience */\r\n aud: string;\r\n /** 用户 ID(Back-Channel Logout 1.0:sub 与 sid 至少其一) */\r\n sub?: string;\r\n /** 签发时间(UNIX timestamp) */\r\n iat: number;\r\n /** JWT ID(唯一标识,防重放) */\r\n jti: string;\r\n /** Backchannel logout 事件声明 */\r\n events: {\r\n \"http://schemas.openid.net/event/backchannel-logout\": Record<string, never>;\r\n };\r\n /** sid (Session ID),可选 */\r\n sid?: string;\r\n}\r\n\r\ninterface IntrospectResponse {\r\n active: boolean;\r\n sub?: string;\r\n aud?: string | string[];\r\n client_id?: string;\r\n scope?: string;\r\n exp?: number;\r\n token_type?: string;\r\n client_type?: \"m2m\" | \"user\";\r\n sid?: string;\r\n auth_time?: number;\r\n cnf?: { jkt?: string };\r\n [key: string]: unknown;\r\n}\r\n\r\n// ============================================\r\n// Introspection 结果缓存\r\n// ============================================\r\n\r\ninterface IntrospectCacheEntry {\r\n active: boolean;\r\n payload: VerifiedTokenPayload | null;\r\n}\r\n\r\nfunction createIntrospectCache(ttlMs: number) {\r\n return new LRUCache<string, IntrospectCacheEntry>({\r\n max: 10000,\r\n ttl: ttlMs,\r\n });\r\n}\r\n\r\n// ============================================\r\n// Logout Token jti 重放防护缓存\r\n// ============================================\r\n\r\n/**\r\n * 已处理的 logout_token jti 缓存。\r\n * TTL 10 分钟:logout token 本身有效期 5 分钟,留足时钟偏移余量。\r\n */\r\nconst processedLogoutJtis = new LRUCache<string, number>({\r\n max: 10000,\r\n ttl: 10 * 60 * 1000,\r\n});\r\n\r\n// 废弃的 HS256 logout_token 回退(accessTokenSecret 代验)告警文案,\r\n// 构造时与首次实际使用时复用同一条消息\r\nconst LEGACY_LOGOUT_SECRET_FALLBACK_WARNING =\r\n \"[sso-verify] 正在使用 accessTokenSecret 验证 HS256 logout_token(废弃路径):\" +\r\n \"共享 access secret 的服务可伪造任意用户登出。\" +\r\n \"请改用 logoutTokenSecret,或配置 logoutTokenPublicKey/jwksUri 走 RS256。\";\r\n\r\n// 非生产环境下 http:// 非 loopback 端点的告警去重(每个端点名每进程只提示一次)\r\nconst warnedInsecureEndpoints = new Set<string>();\r\n\r\n// ============================================\r\n// Token Verifier\r\n// ============================================\r\n\r\nexport function createTokenVerifier(options: SsoVerifierOptions) {\r\n const {\r\n audience,\r\n issuer = \"https://nihplod.cn\",\r\n introspectionEndpoint,\r\n clientId,\r\n clientSecret,\r\n accessTokenSecret,\r\n accessTokenPublicKey,\r\n jwksUri,\r\n logoutTokenSecret,\r\n logoutTokenPublicKey,\r\n allowLegacyAccessTokenSecretFallback = false,\r\n introspectCacheTtl = 30 * 1000,\r\n introspectTimeoutMs = 10 * 1000,\r\n introspectNegativeCacheTtl = 5 * 1000,\r\n introspectRetries = 1,\r\n clockToleranceSeconds = 60,\r\n logoutJtiStore,\r\n strictAudience = true,\r\n verificationMode = \"auto\",\r\n allowM2mTokens = false,\r\n } = options;\r\n\r\n // 一次性告警标志:使用废弃的 accessTokenSecret 回退验证 logout_token 时提示迁移\r\n let warnedLegacyLogoutSecret = false;\r\n\r\n // 构造时即提示:显式开启回退且该路径实际可生效(有 accessTokenSecret、无 logoutTokenSecret)\r\n if (allowLegacyAccessTokenSecretFallback && accessTokenSecret && !logoutTokenSecret) {\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n\r\n // 端点传输安全:生产环境禁止 http://(introspection 会携带 client_secret)\r\n // 允许 localhost/127.0.0.1/::1 方便本地联调。\r\n // 非生产环境下 http:// 非 loopback 端点输出一次性告警(不阻断本地联调)。\r\n const assertHttpsEndpoint = (value: string | undefined, name: string): void => {\r\n if (!value) return;\r\n let url: URL;\r\n try {\r\n url = new URL(value);\r\n } catch {\r\n throw new Error(`[sso-verify] ${name} 不是合法 URL: ${value}`);\r\n }\r\n if (url.protocol === \"https:\") return;\r\n const host = url.hostname.replace(/^\\[|\\]$/g, \"\");\r\n const isLoopback = host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\r\n if (url.protocol === \"http:\" && isLoopback) return;\r\n if (process.env.NODE_ENV !== \"production\") {\r\n if (url.protocol === \"http:\" && !warnedInsecureEndpoints.has(name)) {\r\n warnedInsecureEndpoints.add(name);\r\n console.warn(\r\n `[sso-verify] ${name} 使用非加密的 http:// 且非 loopback 地址;` +\r\n \"生产环境(NODE_ENV=production)将直接抛错\"\r\n );\r\n }\r\n return;\r\n }\r\n throw new Error(\r\n `[sso-verify] 生产环境 ${name} 必须为 https://(当前 ${url.protocol}//${url.host}),` +\r\n \"避免凭证/令牌经明文传输\"\r\n );\r\n };\r\n assertHttpsEndpoint(introspectionEndpoint, \"introspectionEndpoint\");\r\n assertHttpsEndpoint(jwksUri, \"jwksUri\");\r\n\r\n const introspectCache = createIntrospectCache(introspectCacheTtl);\r\n\r\n // RS256 JWKS 远程密钥集(延迟初始化)\r\n let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;\r\n function getJwksKeySet() {\r\n if (!jwksUri) return null;\r\n if (!_jwksKeySet) {\r\n _jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {\r\n cacheMaxAge: 60 * 60 * 1000, // 1 小时缓存\r\n });\r\n }\r\n return _jwksKeySet;\r\n }\r\n\r\n // RS256 公钥(延迟导入)\r\n let _rs256PublicKey: KeyLike | null = null;\r\n let _rs256KeyInitialized = false;\r\n async function getRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_rs256KeyInitialized) {\r\n _rs256KeyInitialized = true;\r\n if (!accessTokenPublicKey) {\r\n _rs256PublicKey = null;\r\n } else {\r\n try {\r\n _rs256PublicKey = await importSPKI(accessTokenPublicKey, \"RS256\");\r\n } catch {\r\n _rs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _rs256PublicKey;\r\n }\r\n\r\n // Logout Token RS256 公钥(延迟导入,独立密钥对,kid: logout-token-rs256-v1)\r\n let _logoutRs256PublicKey: KeyLike | null = null;\r\n let _logoutRs256KeyInitialized = false;\r\n async function getLogoutRS256PublicKey(): Promise<KeyLike | null> {\r\n if (!_logoutRs256KeyInitialized) {\r\n _logoutRs256KeyInitialized = true;\r\n if (!logoutTokenPublicKey) {\r\n _logoutRs256PublicKey = null;\r\n } else {\r\n try {\r\n _logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, \"RS256\");\r\n } catch {\r\n _logoutRs256PublicKey = null;\r\n }\r\n }\r\n }\r\n return _logoutRs256PublicKey;\r\n }\r\n\r\n /**\r\n * Introspection 响应的 aud 归属校验(防 confused deputy):\r\n * - 响应携带 aud(字符串或数组)时必须包含配置的 audience;\r\n * - 否则若携带 client_id,必须等于 audience(audience 即 client_id);\r\n * - 两者都缺失时:strictAudience=false 时保持信任(兼容不返回归属字段的端点);\r\n * 默认 strictAudience=true 时 fail-closed 拒绝。\r\n */\r\n function matchesAudience(data: IntrospectResponse): boolean {\r\n const aud = data.aud;\r\n if (typeof aud === \"string\") return aud === audience;\r\n if (Array.isArray(aud)) return aud.includes(audience);\r\n if (typeof data.client_id === \"string\") return data.client_id === audience;\r\n return !strictAudience;\r\n }\r\n\r\n // 同一 token 的并发 introspect 共享同一个 Promise,避免重复请求主站\r\n const inflightIntrospects = new Map<string, Promise<IntrospectResponse | null>>();\r\n\r\n /**\r\n * 调用主站 Introspection 端点验证 token\r\n *\r\n * Public Client 可省略 client_secret,仅传 client_id。\r\n * Confidential Client 建议传入 client_secret 以通过端点认证。\r\n */\r\n async function introspect(token: string): Promise<IntrospectResponse | null> {\r\n if (!introspectionEndpoint || !clientId) {\r\n return null;\r\n }\r\n\r\n const cached = introspectCache.get(token);\r\n if (cached) {\r\n return { active: cached.active, ...(cached.payload || {}) };\r\n }\r\n\r\n const inflight = inflightIntrospects.get(token);\r\n if (inflight) {\r\n return inflight;\r\n }\r\n\r\n const promise = doIntrospect(token).finally(() => {\r\n inflightIntrospects.delete(token);\r\n });\r\n inflightIntrospects.set(token, promise);\r\n return promise;\r\n }\r\n\r\n async function doIntrospect(token: string): Promise<IntrospectResponse | null> {\r\n const params = new URLSearchParams({ token, client_id: clientId! });\r\n if (clientSecret) {\r\n params.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 网络错误 / 5xx 短退避重试(introspectRetries 次),4xx 不重试\r\n let data: IntrospectResponse | null = null;\r\n for (let attempt = 0; ; attempt++) {\r\n if (attempt > 0) {\r\n await new Promise((resolve) => setTimeout(resolve, 200 * attempt));\r\n }\r\n let response: Response;\r\n try {\r\n response = await fetch(introspectionEndpoint!, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: params,\r\n signal: AbortSignal.timeout(introspectTimeoutMs),\r\n });\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n if (!response.ok) {\r\n // 5xx 与 429(限流)可重试;429 通常需要更长窗口,但仍先短退避尝试一次\r\n if ((response.status >= 500 || response.status === 429) && attempt < introspectRetries) {\r\n continue;\r\n }\r\n return null;\r\n }\r\n // 响应体非法 JSON(网关/代理返回 HTML、空体、截断 JSON 等)按端点不可用处理:\r\n // 重试后仍失败则 fail-closed 返回 null,避免异常穿透到 verify/中间件\r\n try {\r\n data = (await response.json()) as IntrospectResponse;\r\n } catch {\r\n if (attempt < introspectRetries) continue;\r\n return null;\r\n }\r\n break;\r\n }\r\n if (!data) return null;\r\n\r\n // active 必须是严格布尔 true:非布尔(如字符串 \"false\"/1)一律视为不可用\r\n if (data.active !== true) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n // aud 归属不匹配(token 颁发给其他 client)视为无效,按负缓存处理\r\n if (!matchesAudience(data)) {\r\n if (introspectNegativeCacheTtl > 0) {\r\n introspectCache.set(token, { active: false, payload: null }, {\r\n ttl: introspectNegativeCacheTtl,\r\n });\r\n }\r\n return { active: false };\r\n }\r\n\r\n let payload: VerifiedTokenPayload | null = null;\r\n if (data.sub) {\r\n payload = {\r\n sub: data.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: data.client_id,\r\n scope: data.scope,\r\n exp: data.exp,\r\n ...(data.client_type ? { client_type: data.client_type } : {}),\r\n ...(data.sid ? { sid: data.sid } : {}),\r\n ...(typeof data.auth_time === \"number\" ? { auth_time: data.auth_time } : {}),\r\n ...(data.cnf ? { cnf: data.cnf } : {}),\r\n } as VerifiedTokenPayload;\r\n }\r\n\r\n // 正缓存 TTL 不得超过 token 自身剩余有效期(避免缓存结果比 token 活得更久);\r\n // 已过期/无剩余有效期时不缓存。\r\n let ttl = introspectCacheTtl;\r\n if (typeof data.exp === \"number\") {\r\n ttl = Math.min(ttl, data.exp * 1000 - Date.now());\r\n }\r\n if (ttl > 0) {\r\n introspectCache.set(token, { active: true, payload }, { ttl });\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 本地验签路径返回的是 token 原始 payload:主站 access token 把用户 ID\r\n * 放在 `id` claim(`sub` 只存在于 id_token 与 introspect 响应)。此处\r\n * 归一化为 `sub`,保持与 introspect 路径一致的返回契约——消费方\r\n * (session-init / getSessionUser 等)一律读 payload.sub。\r\n * 两者皆无时视为无效 token(对齐 introspect 路径\"必须有非空 sub\"的口径)。\r\n */\r\n function normalizeLocalPayload(payload: JWTPayload): VerifiedTokenPayload | null {\r\n const p = payload as VerifiedTokenPayload & { id?: string };\r\n if (!p.sub && typeof p.id === \"string\" && p.id) {\r\n p.sub = p.id;\r\n }\r\n return p.sub ? p : null;\r\n }\r\n\r\n /**\r\n * 本地 JWT 验证(仅当提供 accessTokenSecret 时,使用 HS256)\r\n */\r\n async function verifyLocally(token: string): Promise<VerifiedTokenPayload | null> {\r\n if (!accessTokenSecret) return null;\r\n\r\n try {\r\n const secret = new TextEncoder().encode(accessTokenSecret);\r\n const { payload } = await jwtVerify(token, secret, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n\r\n if ((payload as { type?: string }).type !== \"access_token\") {\r\n return null;\r\n }\r\n\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * RS256 本地验证(优先直接公钥,验证失败回退 JWKS 远程获取)\r\n */\r\n async function verifyWithRS256(token: string): Promise<VerifiedTokenPayload | null> {\r\n // 检查是否有 RS256 验证能力\r\n const hasPublicKey = Boolean(accessTokenPublicKey);\r\n const hasJwks = Boolean(jwksUri);\r\n if (!hasPublicKey && !hasJwks) return null;\r\n\r\n try {\r\n // 优先使用直接公钥;验证失败(如主站轮换密钥后本地公钥过期)\r\n // 时回退 JWKS,而不是直接判定失败\r\n const directKey = await getRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // 回退到 JWKS 远程获取\r\n const jwks = getJwksKeySet();\r\n if (jwks) {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n if ((payload as { type?: string }).type !== \"access_token\") return null;\r\n return normalizeLocalPayload(payload);\r\n }\r\n\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n const hasIntrospection = Boolean(introspectionEndpoint && clientId);\r\n\r\n /**\r\n * M2M token 默认拒绝用户态校验:其 sub 是 `client:<id>`,不是用户 ID。\r\n * 需要校验 M2M token 的服务显式设置 allowM2mTokens: true,并自行区分 client_type。\r\n */\r\n const rejectM2mPayload = (payload: VerifiedTokenPayload | null): VerifiedTokenPayload | null => {\r\n if (!payload || allowM2mTokens) return payload;\r\n const isM2m = payload.client_type === \"m2m\" || payload.sub.startsWith(\"client:\");\r\n return isM2m ? null : payload;\r\n };\r\n\r\n const verifyLocallyBoth = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n // 1. HS256 本地验证(适用于共享 secret 的内部服务)\r\n if (accessTokenSecret) {\r\n const local = await verifyLocally(rawToken);\r\n if (local) return local;\r\n }\r\n // 2. RS256 本地验证(适用于已迁移到非对称签名的子项目)\r\n return verifyWithRS256(rawToken);\r\n };\r\n\r\n const verifyViaIntrospection = async (rawToken: string): Promise<VerifiedTokenPayload | null> => {\r\n const result = await introspect(rawToken);\r\n // active 必须严格为 true,且必须携带非空 sub,否则视为无效(防 confused deputy)\r\n if (result?.active !== true || !result.sub) return null;\r\n return {\r\n sub: result.sub,\r\n aud: audience,\r\n iss: issuer,\r\n client_id: result.client_id,\r\n scope: result.scope,\r\n exp: result.exp,\r\n ...(result.client_type ? { client_type: result.client_type } : {}),\r\n ...(result.sid ? { sid: result.sid } : {}),\r\n ...(typeof result.auth_time === \"number\" ? { auth_time: result.auth_time } : {}),\r\n ...(result.cnf ? { cnf: result.cnf } : {}),\r\n ...(result.token_type ? { token_type: result.token_type } : {}),\r\n } as VerifiedTokenPayload;\r\n };\r\n\r\n return {\r\n /**\r\n * 验证 Access Token\r\n *\r\n * 模式由 verificationMode 控制(默认 \"auto\"):\r\n * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。\r\n * - introspection:强制 Introspection(未配置端点时回退本地)。\r\n * - local:仅本地验签(不感知撤销)。\r\n * - hybrid:本地优先,失败后 Introspection(旧版行为)。\r\n *\r\n * @returns token payload 或者 null(验证失败)\r\n */\r\n async verify(rawToken: string): Promise<VerifiedTokenPayload | null> {\r\n const runLocal = async () => rejectM2mPayload(await verifyLocallyBoth(rawToken));\r\n const runIntrospection = async () =>\r\n rejectM2mPayload(await verifyViaIntrospection(rawToken));\r\n\r\n if (verificationMode === \"local\") {\r\n return await runLocal();\r\n }\r\n if (verificationMode === \"introspection\") {\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n }\r\n if (verificationMode === \"hybrid\") {\r\n const local = await runLocal();\r\n if (local) return local;\r\n return hasIntrospection ? await runIntrospection() : null;\r\n }\r\n // auto:Introspection 配置存在时默认走 Introspection,避免本地验签绕过撤销。\r\n return hasIntrospection ? await runIntrospection() : await runLocal();\r\n },\r\n\r\n /**\r\n * 直接调用 Introspection 端点\r\n */\r\n introspect,\r\n\r\n /**\r\n * 清除指定 token 的 Introspection 缓存\r\n */\r\n invalidateCache(token: string): void {\r\n introspectCache.delete(token);\r\n },\r\n\r\n /**\r\n * 验证 Logout Token(Back-Channel Logout)\r\n *\r\n * 用于子项目接收主站 backchannel logout 通知时验证 logout_token。\r\n * 验证要求(OIDC Back-Channel Logout 1.0):\r\n * 1. type === \"logout_token\"\r\n * 2. iss 匹配配置的 issuer\r\n * 3. aud 包含当前 client_id\r\n * 4. exp 存在(规范要求)\r\n * 5. events 包含 backchannel-logout 事件,且事件值为对象\r\n * 6. sub 或 sid 至少其一(§2.4,否则无法定位要终止的会话)\r\n *\r\n * @returns LogoutTokenPayload 或 null(验证失败)\r\n */\r\n async verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null> {\r\n // 辅助:通用 payload 校验(type / exp / events / jti 防重放)\r\n const validateLogoutPayload = async (\r\n payload: Record<string, unknown>\r\n ): Promise<LogoutTokenPayload | null> => {\r\n if ((payload as { type?: string }).type !== \"logout_token\") {\r\n return null;\r\n }\r\n // 规范要求 logout_token 必须携带 exp / iat,且不得携带 nonce\r\n if (typeof (payload as { exp?: unknown }).exp !== \"number\") {\r\n return null;\r\n }\r\n if (typeof (payload as { iat?: unknown }).iat !== \"number\") {\r\n return null;\r\n }\r\n if ((payload as { nonce?: unknown }).nonce !== undefined) {\r\n return null;\r\n }\r\n const events = (payload as { events?: Record<string, unknown> }).events;\r\n const logoutEvent =\r\n events?.[\"http://schemas.openid.net/event/backchannel-logout\"];\r\n // 事件值必须是对象(通常为空对象 {})\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n return null;\r\n }\r\n const jti = (payload as { jti?: string }).jti;\r\n if (!jti || typeof jti !== \"string\") {\r\n return null;\r\n }\r\n // Back-Channel Logout 1.0 §2.4:sub 与 sid 至少存在一个,\r\n // 否则无法定位要终止的会话/用户,按验证失败处理\r\n const sub = (payload as { sub?: unknown }).sub;\r\n const sid = (payload as { sid?: unknown }).sid;\r\n const hasSub = typeof sub === \"string\" && sub.length > 0;\r\n const hasSid = typeof sid === \"string\" && sid.length > 0;\r\n if (!hasSub && !hasSid) {\r\n return null;\r\n }\r\n // jti 缓存 key 带 issuer 前缀,避免跨 issuer / 跨 verifier 实例的 jti 冲突\r\n const iss = (payload as { iss?: string }).iss || issuer;\r\n const jtiKey = `${iss}:${jti}`;\r\n if (logoutJtiStore) {\r\n // 优先原子接口(Redis SET NX EX 等):消除 has + add 之间的 TOCTOU 窗口\r\n if (typeof logoutJtiStore.addIfAbsent === \"function\") {\r\n const firstUse = await logoutJtiStore.addIfAbsent(jtiKey, 10 * 60);\r\n if (!firstUse) return null;\r\n } else {\r\n // 兼容旧实现:has + add 非原子,并发/跨实例下可能重复接受同一 jti;\r\n // 多实例部署请补充 addIfAbsent 实现\r\n if (await logoutJtiStore.has(jtiKey)) {\r\n return null;\r\n }\r\n await logoutJtiStore.add(jtiKey, 10 * 60);\r\n }\r\n } else {\r\n if (processedLogoutJtis.has(jtiKey)) {\r\n return null;\r\n }\r\n processedLogoutJtis.set(jtiKey, Date.now());\r\n }\r\n return payload as unknown as LogoutTokenPayload;\r\n };\r\n\r\n // 按 JWT header alg 分发,避免跨算法静默回退\r\n let alg: string | undefined;\r\n try {\r\n alg = decodeProtectedHeader(token).alg;\r\n } catch {\r\n return null;\r\n }\r\n\r\n // 1. RS256:使用 logout token 独立公钥(logoutTokenPublicKey)或 JWKS(按 kid 匹配)。\r\n // 直接公钥验证失败(如密钥轮换后本地公钥过期)时回退 JWKS;\r\n // 无任何可用公钥时直接失败,不回退 HS256。\r\n if (alg === \"RS256\") {\r\n const directKey = await getLogoutRS256PublicKey();\r\n if (directKey) {\r\n try {\r\n const { payload } = await jwtVerify(token, directKey, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n // 直接公钥验证失败,继续尝试 JWKS\r\n }\r\n }\r\n\r\n // jwksUri 非法(或生产环境非 https)时 getJwksKeySet 可能抛错:\r\n // 与 verify() 一致按验证失败处理,而不是异常穿透\r\n let jwks: ReturnType<typeof getJwksKeySet> = null;\r\n try {\r\n jwks = getJwksKeySet();\r\n } catch {\r\n return null;\r\n }\r\n if (jwks) {\r\n try {\r\n const { payload } = await jwtVerify(token, jwks, {\r\n issuer,\r\n audience,\r\n algorithms: [\"RS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n // 2. HS256:使用 logoutTokenSecret(专用密钥);回退 accessTokenSecret 默认关闭\r\n if (alg === \"HS256\") {\r\n const secret =\r\n logoutTokenSecret ||\r\n (allowLegacyAccessTokenSecretFallback ? accessTokenSecret : undefined);\r\n if (!secret) return null;\r\n if (!logoutTokenSecret && allowLegacyAccessTokenSecretFallback) {\r\n if (!warnedLegacyLogoutSecret) {\r\n warnedLegacyLogoutSecret = true;\r\n console.warn(LEGACY_LOGOUT_SECRET_FALLBACK_WARNING);\r\n }\r\n }\r\n\r\n try {\r\n const key = new TextEncoder().encode(secret);\r\n const { payload } = await jwtVerify(token, key, {\r\n issuer,\r\n audience,\r\n algorithms: [\"HS256\"],\r\n requiredClaims: [\"exp\"],\r\n clockTolerance: clockToleranceSeconds,\r\n });\r\n return await validateLogoutPayload(payload as unknown as Record<string, unknown>);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n return null;\r\n },\r\n };\r\n}\r\n\r\n/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */\r\nexport interface SsoMiddlewareRequest {\r\n headers?: Record<string, string | undefined>;\r\n /** 验证通过后由中间件挂载的用户信息 */\r\n user?: VerifiedTokenPayload;\r\n [key: string]: unknown;\r\n}\r\n\r\n/** 框架无关的最小中间件响应类型 */\r\nexport interface SsoMiddlewareResponse {\r\n status?: (code: number) => { json: (body: unknown) => unknown };\r\n [key: string]: unknown;\r\n}\r\n\r\n/**\r\n * Express/Next.js 兼容中间件\r\n *\r\n * 使用示例(Express):\r\n * ```typescript\r\n * app.use(ssoMiddleware({\r\n * introspectionEndpoint: \"https://nihplod.cn/api/oauth/introspect\",\r\n * clientId: \"advisor\",\r\n * clientSecret: \"SECRET\",\r\n * audience: \"advisor\",\r\n * }));\r\n * ```\r\n *\r\n * 使用示例(Next.js Route Handler):\r\n * ```typescript\r\n * const verifier = createTokenVerifier({ ... });\r\n * export async function GET(request: NextRequest) {\r\n * const token = request.headers.get(\"authorization\")?.replace(\"Bearer \", \"\");\r\n * const payload = token ? await verifier.verify(token) : null;\r\n * if (!payload) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\r\n * // ...\r\n * }\r\n * ```\r\n */\r\nexport function ssoMiddleware(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return async (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => {\r\n const authHeader = req.headers?.authorization || req.headers?.Authorization || \"\";\r\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\r\n\r\n if (!token) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"请提供 Bearer token\" });\r\n return;\r\n }\r\n // res 不支持 status/json 时通过 next(err) 交由错误处理中间件,避免请求挂起\r\n next(new Error(\"Unauthorized: 请提供 Bearer token\"));\r\n return;\r\n }\r\n\r\n const payload = await verifier.verify(token);\r\n if (!payload) {\r\n if (res.status) {\r\n res.status(401).json({ error: \"Unauthorized\", message: \"Token 无效或已过期\" });\r\n return;\r\n }\r\n next(new Error(\"Unauthorized: Token 无效或已过期\"));\r\n return;\r\n }\r\n\r\n // 挂载用户信息到请求对象\r\n req.user = payload;\r\n next();\r\n };\r\n}\r\n\r\n/**\r\n * 创建 Logout Token 专用验证器\r\n *\r\n * 适用于仅需处理 backchannel logout 的子项目。\r\n * 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。\r\n *\r\n * 使用示例:\r\n * ```typescript\r\n * import { createLogoutTokenVerifier } from \"@nihplod/sso-verify\";\r\n *\r\n * const logoutVerifier = createLogoutTokenVerifier({\r\n * audience: \"advisor\",\r\n * issuer: \"https://nihplod.cn\",\r\n * logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,\r\n * });\r\n *\r\n * // 在 backchannel logout 端点中:\r\n * app.post(\"/api/auth/backchannel-logout\", async (req, res) => {\r\n * const token = req.body.logout_token;\r\n * const payload = await logoutVerifier.verify(token);\r\n * if (payload) {\r\n * await clearUserSession(payload.sub);\r\n * res.status(200).end();\r\n * } else {\r\n * res.status(400).end();\r\n * }\r\n * });\r\n * ```\r\n */\r\nexport function createLogoutTokenVerifier(options: SsoVerifierOptions) {\r\n const verifier = createTokenVerifier(options);\r\n\r\n return {\r\n /**\r\n * 验证 logout_token\r\n */\r\n async verify(token: string): Promise<LogoutTokenPayload | null> {\r\n return verifier.verifyLogoutToken(token);\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nihplod/sso-verify",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",