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