@nihplod/sso-verify 1.2.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,154 +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
- | `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
107
- | `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 |
108
-
109
- ### Revocation latency trade-off
110
-
111
- Introspection results are cached to reduce latency and load on the main site. This means a revoked access token may remain accepted locally until its cache entry expires. `active: true` results are cached for `introspectCacheTtl` (default 30s, 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.
112
-
113
- Concurrent `verify()` calls for the same token share a single in-flight introspection request, and failed requests (network errors / 5xx) are retried once by default — see `introspectRetries`.
114
-
115
- ### Audience (aud) binding for introspection
116
-
117
- When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
118
-
119
- - If the response contains `aud` (string or array), it must include the configured `audience`.
120
- - Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
121
- - If the endpoint returns neither field, the response is 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).
122
-
123
- > ⚠️ **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.
124
-
125
- ### Logout token verification
126
-
127
- Back-channel `logout_token`s are signed with a dedicated key pair (`kid: logout-token-rs256-v1`), which is **different** from the access token key — do not use `accessTokenPublicKey` for them. Configure `logoutTokenPublicKey` (PEM) or `jwksUri` (keys matched by `kid`). Verification is dispatched by the JWT header `alg`: an RS256 logout token without any matching public key fails closed (returns `null`) instead of silently falling back to HS256; HS256 is only used when `logoutTokenSecret` (or the legacy `accessTokenSecret` fallback) is explicitly configured.
128
-
129
- Per OIDC Back-Channel Logout 1.0, a `logout_token` must carry an `exp` claim, an `events` claim whose `http://schemas.openid.net/event/backchannel-logout` member is an object (typically `{}`), and at least one of `sub` / `sid` (§2.4); tokens failing any of these checks are rejected. Verified `jti`s are replay-guarded per issuer for 10 minutes.
130
-
131
- When the IdP revokes sessions administratively (consent revoke, session termination, user ban/deletion), the `logout_token` also carries a `sid` claim matching the terminated IdP session — RPs that track IdP `sid`s should invalidate the matching local session and may fall back to `sub`-level logout when `sid` is absent (e.g. user-initiated IdP logout without a known session). Failed deliveries are retried by the IdP with exponential backoff (up to 10 attempts), so RPs may receive a delayed `logout_token`; the `jti` replay guard still applies.
132
-
133
- ### Multi-instance deployments (logout jti replay store)
134
-
135
- By default, processed `logout_token` jtis are kept in an in-process LRU cache — it is emptied on restart and **not shared across instances**, so a replayed logout token could be accepted by another instance. When running multiple instances, inject a shared store (e.g. backed by Redis) via `logoutJtiStore`:
136
-
137
- ```typescript
138
- const verifier = createTokenVerifier({
139
- audience: "your-client-id",
140
- logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
141
- logoutJtiStore: {
142
- // Both sync and async implementations are supported.
143
- // addIfAbsent is strongly recommended: it must atomically SET NX EX and
144
- // return true only when this call created the key (first use).
145
- addIfAbsent: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds, "NX").then((r) => r === "OK"),
146
- // has/add are the legacy fallback used only when addIfAbsent is not provided
147
- // (non-atomic check-then-act; single-instance use only).
148
- has: (key) => redis.exists(key).then((n) => n > 0),
149
- add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
150
- },
151
- });
152
- ```
153
-
154
- `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
@@ -70,32 +70,52 @@ interface SsoVerifierOptions {
70
70
  * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
71
  * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
72
  * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
73
+ * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS
74
+ * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。
73
75
  */
74
76
  accessTokenPublicKey?: string;
75
77
  /**
76
78
  * JWKS 端点 URL(可选)。
77
79
  * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。
78
- * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥。
80
+ * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;
81
+ * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。
79
82
  */
80
83
  jwksUri?: string;
81
84
  /**
82
85
  * Logout Token Secret(可选)。
83
86
  * 用于本地验证主站签发的 logout_token(HS256 签名)。
84
- * 若未提供,将回退使用 accessTokenSecret 进行验证。
87
+ * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token
88
+ * 验证将失败(返回 null)。
85
89
  */
86
90
  logoutTokenSecret?: string;
91
+ /**
92
+ * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。
93
+ *
94
+ * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。
95
+ * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),
96
+ * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。
97
+ * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。
98
+ *
99
+ * 开启后会在两处输出告警(console.warn):
100
+ * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置
101
+ * logoutTokenSecret)立即提示一次;
102
+ * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发
103
+ * (一次性告警)。
104
+ */
105
+ allowLegacyAccessTokenSecretFallback?: boolean;
87
106
  /**
88
107
  * Logout Token RS256 公钥(PEM 格式,可选)。
89
108
  * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
90
109
  * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
91
- * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
110
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;
111
+ * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。
92
112
  * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
93
113
  * 不会静默回退到 HS256。
94
114
  */
95
115
  logoutTokenPublicKey?: string;
96
116
  /**
97
117
  * Introspection 请求失败重试次数,默认 1。
98
- * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
118
+ * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。
99
119
  * 设为 0 表示不重试。
100
120
  */
101
121
  introspectRetries?: number;
@@ -120,6 +140,23 @@ interface SsoVerifierOptions {
120
140
  * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
121
141
  */
122
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;
123
160
  }
124
161
  /**
125
162
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -142,6 +179,18 @@ interface VerifiedTokenPayload extends JWTPayload {
142
179
  client_id?: string;
143
180
  scope?: string;
144
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";
145
194
  }
146
195
  /**
147
196
  * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
@@ -181,20 +230,28 @@ interface IntrospectResponse {
181
230
  client_id?: string;
182
231
  scope?: string;
183
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
+ };
184
240
  [key: string]: unknown;
185
241
  }
186
242
  declare function createTokenVerifier(options: SsoVerifierOptions): {
187
243
  /**
188
244
  * 验证 Access Token
189
245
  *
190
- * 验证顺序:
191
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
192
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
193
- * 3. 若上述均失败/未配置,调用 Introspection 端点
246
+ * 模式由 verificationMode 控制(默认 "auto"):
247
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
248
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
249
+ * - local:仅本地验签(不感知撤销)。
250
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
194
251
  *
195
252
  * @returns token payload 或者 null(验证失败)
196
253
  */
197
- verify(token: string): Promise<VerifiedTokenPayload | null>;
254
+ verify(rawToken: string): Promise<VerifiedTokenPayload | null>;
198
255
  /**
199
256
  * 直接调用 Introspection 端点
200
257
  */
package/dist/index.d.ts CHANGED
@@ -70,32 +70,52 @@ interface SsoVerifierOptions {
70
70
  * 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
71
71
  * 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
72
72
  * 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
73
+ * 同时配置 jwksUri 时优先尝试此直接公钥,验证失败后自动回退 JWKS
74
+ * (覆盖主站密钥轮换后旧公钥尚未更新的过渡期)。
73
75
  */
74
76
  accessTokenPublicKey?: string;
75
77
  /**
76
78
  * JWKS 端点 URL(可选)。
77
79
  * 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。
78
- * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥。
80
+ * 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥;
81
+ * 两者同时配置时先尝试直接公钥,失败后回退本端点(密钥轮换兜底)。
79
82
  */
80
83
  jwksUri?: string;
81
84
  /**
82
85
  * Logout Token Secret(可选)。
83
86
  * 用于本地验证主站签发的 logout_token(HS256 签名)。
84
- * 若未提供,将回退使用 accessTokenSecret 进行验证。
87
+ * 若未提供且未显式开启 allowLegacyAccessTokenSecretFallback,HS256 logout_token
88
+ * 验证将失败(返回 null)。
85
89
  */
86
90
  logoutTokenSecret?: string;
91
+ /**
92
+ * 允许 HS256 logout_token 回退使用 accessTokenSecret 验证(默认 false,废弃路径)。
93
+ *
94
+ * 历史行为:未配置 logoutTokenSecret 时回退使用 accessTokenSecret 验证。
95
+ * 风险:合法共享 access secret 的内部服务即可伪造任意用户的 logout_token(强制全站登出),
96
+ * access secret 泄漏会同时击穿 access token 与 logout token 两条信任边界。
97
+ * 仅当无法立即迁移旧部署时显式设为 true,并尽快改用 logoutTokenSecret 或 RS256 公钥。
98
+ *
99
+ * 开启后会在两处输出告警(console.warn):
100
+ * 1. 构造 verifier 时——若该回退路径实际可生效(配置了 accessTokenSecret 但未配置
101
+ * logoutTokenSecret)立即提示一次;
102
+ * 2. 首次实际使用回退路径验证 HS256 logout_token 时——提示该路径正在被真实流量触发
103
+ * (一次性告警)。
104
+ */
105
+ allowLegacyAccessTokenSecretFallback?: boolean;
87
106
  /**
88
107
  * Logout Token RS256 公钥(PEM 格式,可选)。
89
108
  * 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
90
109
  * 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
91
- * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
110
+ * 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥;
111
+ * 两者同时配置时先尝试此直接公钥,验证失败后回退 JWKS(密钥轮换兜底)。
92
112
  * RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
93
113
  * 不会静默回退到 HS256。
94
114
  */
95
115
  logoutTokenPublicKey?: string;
96
116
  /**
97
117
  * Introspection 请求失败重试次数,默认 1。
98
- * 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
118
+ * 仅对网络错误、5xx 与 429(限流)响应重试(短退避),其余 4xx 不重试。
99
119
  * 设为 0 表示不重试。
100
120
  */
101
121
  introspectRetries?: number;
@@ -120,6 +140,23 @@ interface SsoVerifierOptions {
120
140
  * 仅在对接不返回归属字段的第三方端点时才需要显式设为 false(不推荐)。
121
141
  */
122
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;
123
160
  }
124
161
  /**
125
162
  * Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
@@ -142,6 +179,18 @@ interface VerifiedTokenPayload extends JWTPayload {
142
179
  client_id?: string;
143
180
  scope?: string;
144
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";
145
194
  }
146
195
  /**
147
196
  * Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
@@ -181,20 +230,28 @@ interface IntrospectResponse {
181
230
  client_id?: string;
182
231
  scope?: string;
183
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
+ };
184
240
  [key: string]: unknown;
185
241
  }
186
242
  declare function createTokenVerifier(options: SsoVerifierOptions): {
187
243
  /**
188
244
  * 验证 Access Token
189
245
  *
190
- * 验证顺序:
191
- * 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
192
- * 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
193
- * 3. 若上述均失败/未配置,调用 Introspection 端点
246
+ * 模式由 verificationMode 控制(默认 "auto"):
247
+ * - auto:配置了 Introspection 时优先 Introspection(实时感知撤销);否则本地验签。
248
+ * - introspection:强制 Introspection(未配置端点时回退本地)。
249
+ * - local:仅本地验签(不感知撤销)。
250
+ * - hybrid:本地优先,失败后 Introspection(旧版行为)。
194
251
  *
195
252
  * @returns token payload 或者 null(验证失败)
196
253
  */
197
- verify(token: string): Promise<VerifiedTokenPayload | null>;
254
+ verify(rawToken: string): Promise<VerifiedTokenPayload | null>;
198
255
  /**
199
256
  * 直接调用 Introspection 端点
200
257
  */