@nihplod/sso-verify 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -3
- package/dist/index.d.mts +76 -10
- package/dist/index.d.ts +76 -10
- package/dist/index.js +181 -45
- package/dist/index.mjs +187 -46
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -86,6 +86,61 @@ After successful verification, the token payload is attached to `req.user`.
|
|
|
86
86
|
|
|
87
87
|
## Notes
|
|
88
88
|
|
|
89
|
-
- The main site
|
|
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
|
+
|
|
93
|
+
## Advanced Options
|
|
94
|
+
|
|
95
|
+
| Option | Default | Description |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `introspectCacheTtl` | `30000` | Cache TTL (ms) for `active: true` introspection results |
|
|
98
|
+
| `introspectNegativeCacheTtl` | `5000` | Cache TTL (ms) for `active: false` results; set `0` to disable caching of revoked/invalid tokens |
|
|
99
|
+
| `introspectTimeoutMs` | `10000` | Introspection request timeout (ms); a timeout is treated as verification failure |
|
|
100
|
+
| `introspectRetries` | `1` | Retries for introspection requests on network errors / 5xx (short backoff); 4xx is never retried; set `0` to disable |
|
|
101
|
+
| `clockToleranceSeconds` | `60` | Clock skew tolerance (seconds) applied to all local JWT verification paths (HS256 / RS256 / logout token) |
|
|
102
|
+
| `accessTokenPublicKey` | — | RS256 public key (PEM) for local access token verification |
|
|
103
|
+
| `jwksUri` | — | JWKS endpoint URL; public keys are matched by `kid` |
|
|
104
|
+
| `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
|
|
105
|
+
| `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
|
|
106
|
+
| `logoutJtiStore` | — | External store for processed `logout_token` jtis (see "Multi-instance deployments" below) |
|
|
107
|
+
|
|
108
|
+
### Revocation latency trade-off
|
|
109
|
+
|
|
110
|
+
Introspection results are cached to reduce latency and load on the main site. This means a revoked access token may remain accepted locally until its cache entry expires. `active: true` results are cached for `introspectCacheTtl` (default 30s); `active: false` results use the much shorter `introspectNegativeCacheTtl` (default 5s), so a previously-invalid token that becomes valid (e.g. re-issued) converges quickly, while revocation of an already-cached token takes at most `introspectCacheTtl`. Lower `introspectCacheTtl` for stronger revocation guarantees at the cost of more introspection calls. Use `verifier.invalidateCache(token)` to evict a specific token immediately.
|
|
111
|
+
|
|
112
|
+
Concurrent `verify()` calls for the same token share a single in-flight introspection request, and failed requests (network errors / 5xx) are retried once by default — see `introspectRetries`.
|
|
113
|
+
|
|
114
|
+
### Audience (aud) binding for introspection
|
|
115
|
+
|
|
116
|
+
When verifying via introspection, the response's audience binding is checked against the configured `audience` (which is the sub-project's `client_id`):
|
|
117
|
+
|
|
118
|
+
- If the response contains `aud` (string or array), it must include the configured `audience`.
|
|
119
|
+
- Otherwise, if the response contains `client_id`, it must equal the configured `audience`.
|
|
120
|
+
- If the endpoint returns neither field, the response is trusted as-is (legacy behavior); the main site's introspection endpoint always returns `client_id`, so tokens issued to other clients are rejected.
|
|
121
|
+
|
|
122
|
+
### Logout token verification
|
|
123
|
+
|
|
124
|
+
Back-channel `logout_token`s are signed with a dedicated key pair (`kid: logout-token-rs256-v1`), which is **different** from the access token key — do not use `accessTokenPublicKey` for them. Configure `logoutTokenPublicKey` (PEM) or `jwksUri` (keys matched by `kid`). Verification is dispatched by the JWT header `alg`: an RS256 logout token without any matching public key fails closed (returns `null`) instead of silently falling back to HS256; HS256 is only used when `logoutTokenSecret` (or the legacy `accessTokenSecret` fallback) is explicitly configured.
|
|
125
|
+
|
|
126
|
+
Per OIDC Back-Channel Logout 1.0, a `logout_token` must carry an `exp` claim and an `events` claim whose `http://schemas.openid.net/event/backchannel-logout` member is an object (typically `{}`); tokens failing either check are rejected. Verified `jti`s are replay-guarded per issuer for 10 minutes.
|
|
127
|
+
|
|
128
|
+
When the IdP revokes sessions administratively (consent revoke, session termination, user ban/deletion), the `logout_token` also carries a `sid` claim matching the terminated IdP session — RPs that track IdP `sid`s should invalidate the matching local session and may fall back to `sub`-level logout when `sid` is absent (e.g. user-initiated IdP logout without a known session). Failed deliveries are retried by the IdP with exponential backoff (up to 10 attempts), so RPs may receive a delayed `logout_token`; the `jti` replay guard still applies.
|
|
129
|
+
|
|
130
|
+
### Multi-instance deployments (logout jti replay store)
|
|
131
|
+
|
|
132
|
+
By default, processed `logout_token` jtis are kept in an in-process LRU cache — it is emptied on restart and **not shared across instances**, so a replayed logout token could be accepted by another instance. When running multiple instances, inject a shared store (e.g. backed by Redis) via `logoutJtiStore`:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
const verifier = createTokenVerifier({
|
|
136
|
+
audience: "your-client-id",
|
|
137
|
+
logoutTokenPublicKey: process.env.LOGOUT_TOKEN_PUBLIC_KEY,
|
|
138
|
+
logoutJtiStore: {
|
|
139
|
+
// Both sync and async implementations are supported.
|
|
140
|
+
has: (key) => redis.exists(key).then((n) => n > 0),
|
|
141
|
+
add: (key, ttlSeconds) => redis.set(key, "1", "EX", ttlSeconds),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`has(key)` reports whether a jti was already processed; `add(key, ttlSeconds)` records it with expiry (the verifier passes 600 seconds, matching the built-in cache).
|
package/dist/index.d.mts
CHANGED
|
@@ -7,10 +7,11 @@ import { JWTPayload } from 'jose';
|
|
|
7
7
|
*
|
|
8
8
|
* 供子项目在后端验证主站签发的 Access Token。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
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) */
|
|
@@ -53,10 +54,22 @@ interface SsoVerifierOptions {
|
|
|
53
54
|
accessTokenSecret?: string;
|
|
54
55
|
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
56
|
introspectCacheTtl?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Introspection 请求超时时间(毫秒),默认 10 秒。
|
|
59
|
+
* 超时按验证失败处理(返回 null)。
|
|
60
|
+
*/
|
|
61
|
+
introspectTimeoutMs?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
|
|
64
|
+
* 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
|
|
65
|
+
* 设为 0 表示不缓存 active:false 结果。
|
|
66
|
+
*/
|
|
67
|
+
introspectNegativeCacheTtl?: number;
|
|
56
68
|
/**
|
|
57
69
|
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
|
-
*
|
|
59
|
-
*
|
|
70
|
+
* 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
|
|
71
|
+
* 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
|
|
72
|
+
* 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
|
|
60
73
|
*/
|
|
61
74
|
accessTokenPublicKey?: string;
|
|
62
75
|
/**
|
|
@@ -71,6 +84,43 @@ interface SsoVerifierOptions {
|
|
|
71
84
|
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
85
|
*/
|
|
73
86
|
logoutTokenSecret?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Logout Token RS256 公钥(PEM 格式,可选)。
|
|
89
|
+
* 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
|
|
90
|
+
* 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
|
|
91
|
+
* 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
|
|
92
|
+
* RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
|
|
93
|
+
* 不会静默回退到 HS256。
|
|
94
|
+
*/
|
|
95
|
+
logoutTokenPublicKey?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Introspection 请求失败重试次数,默认 1。
|
|
98
|
+
* 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
|
|
99
|
+
* 设为 0 表示不重试。
|
|
100
|
+
*/
|
|
101
|
+
introspectRetries?: number;
|
|
102
|
+
/**
|
|
103
|
+
* JWT 时钟偏移容忍(秒),默认 60。
|
|
104
|
+
* 应用于所有本地验签路径(HS256/RS256/logout token),
|
|
105
|
+
* 用于容忍子项目与主站之间的时钟偏差。
|
|
106
|
+
*/
|
|
107
|
+
clockToleranceSeconds?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Logout Token jti 外部存储(可选)。
|
|
110
|
+
* 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);
|
|
111
|
+
* 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
|
|
112
|
+
*/
|
|
113
|
+
logoutJtiStore?: LogoutJtiStore;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
|
|
117
|
+
* has/add 均支持同步或异步返回。
|
|
118
|
+
*/
|
|
119
|
+
interface LogoutJtiStore {
|
|
120
|
+
/** 判断 jti 是否已处理过 */
|
|
121
|
+
has(key: string): boolean | Promise<boolean>;
|
|
122
|
+
/** 记录已处理的 jti,ttlSeconds 后过期 */
|
|
123
|
+
add(key: string, ttlSeconds: number): void | Promise<void>;
|
|
74
124
|
}
|
|
75
125
|
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
126
|
sub: string;
|
|
@@ -114,6 +164,7 @@ interface LogoutTokenPayload {
|
|
|
114
164
|
interface IntrospectResponse {
|
|
115
165
|
active: boolean;
|
|
116
166
|
sub?: string;
|
|
167
|
+
aud?: string | string[];
|
|
117
168
|
client_id?: string;
|
|
118
169
|
scope?: string;
|
|
119
170
|
exp?: number;
|
|
@@ -147,12 +198,27 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
147
198
|
* 1. type === "logout_token"
|
|
148
199
|
* 2. iss 匹配配置的 issuer
|
|
149
200
|
* 3. aud 包含当前 client_id
|
|
150
|
-
* 4.
|
|
201
|
+
* 4. exp 存在(规范要求)
|
|
202
|
+
* 5. events 包含 backchannel-logout 事件,且事件值为对象
|
|
151
203
|
*
|
|
152
204
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
153
205
|
*/
|
|
154
206
|
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
207
|
};
|
|
208
|
+
/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
|
|
209
|
+
interface SsoMiddlewareRequest {
|
|
210
|
+
headers?: Record<string, string | undefined>;
|
|
211
|
+
/** 验证通过后由中间件挂载的用户信息 */
|
|
212
|
+
user?: VerifiedTokenPayload;
|
|
213
|
+
[key: string]: unknown;
|
|
214
|
+
}
|
|
215
|
+
/** 框架无关的最小中间件响应类型 */
|
|
216
|
+
interface SsoMiddlewareResponse {
|
|
217
|
+
status?: (code: number) => {
|
|
218
|
+
json: (body: unknown) => unknown;
|
|
219
|
+
};
|
|
220
|
+
[key: string]: unknown;
|
|
221
|
+
}
|
|
156
222
|
/**
|
|
157
223
|
* Express/Next.js 兼容中间件
|
|
158
224
|
*
|
|
@@ -177,7 +243,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
177
243
|
* }
|
|
178
244
|
* ```
|
|
179
245
|
*/
|
|
180
|
-
declare function ssoMiddleware(options: SsoVerifierOptions): (req:
|
|
246
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
|
|
181
247
|
/**
|
|
182
248
|
* 创建 Logout Token 专用验证器
|
|
183
249
|
*
|
|
@@ -214,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
|
214
280
|
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
281
|
};
|
|
216
282
|
|
|
217
|
-
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
|
283
|
+
export { type LogoutJtiStore, type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.d.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { JWTPayload } from 'jose';
|
|
|
7
7
|
*
|
|
8
8
|
* 供子项目在后端验证主站签发的 Access Token。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
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) */
|
|
@@ -53,10 +54,22 @@ interface SsoVerifierOptions {
|
|
|
53
54
|
accessTokenSecret?: string;
|
|
54
55
|
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
56
|
introspectCacheTtl?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Introspection 请求超时时间(毫秒),默认 10 秒。
|
|
59
|
+
* 超时按验证失败处理(返回 null)。
|
|
60
|
+
*/
|
|
61
|
+
introspectTimeoutMs?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
|
|
64
|
+
* 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
|
|
65
|
+
* 设为 0 表示不缓存 active:false 结果。
|
|
66
|
+
*/
|
|
67
|
+
introspectNegativeCacheTtl?: number;
|
|
56
68
|
/**
|
|
57
69
|
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
|
-
*
|
|
59
|
-
*
|
|
70
|
+
* 主站在配置 JWT_ACCESS_PRIVATE_KEY 后以 RS256 签名 access_token,
|
|
71
|
+
* 子项目可传入此公钥进行本地验证,避免每次都调用 Introspection 端点。
|
|
72
|
+
* 若主站未配置 RS256 密钥(回退 HS256 签名),此选项不生效。
|
|
60
73
|
*/
|
|
61
74
|
accessTokenPublicKey?: string;
|
|
62
75
|
/**
|
|
@@ -71,6 +84,43 @@ interface SsoVerifierOptions {
|
|
|
71
84
|
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
85
|
*/
|
|
73
86
|
logoutTokenSecret?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Logout Token RS256 公钥(PEM 格式,可选)。
|
|
89
|
+
* 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
|
|
90
|
+
* 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
|
|
91
|
+
* 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
|
|
92
|
+
* RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
|
|
93
|
+
* 不会静默回退到 HS256。
|
|
94
|
+
*/
|
|
95
|
+
logoutTokenPublicKey?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Introspection 请求失败重试次数,默认 1。
|
|
98
|
+
* 仅对网络错误与 5xx 响应重试(短退避),4xx 不重试。
|
|
99
|
+
* 设为 0 表示不重试。
|
|
100
|
+
*/
|
|
101
|
+
introspectRetries?: number;
|
|
102
|
+
/**
|
|
103
|
+
* JWT 时钟偏移容忍(秒),默认 60。
|
|
104
|
+
* 应用于所有本地验签路径(HS256/RS256/logout token),
|
|
105
|
+
* 用于容忍子项目与主站之间的时钟偏差。
|
|
106
|
+
*/
|
|
107
|
+
clockToleranceSeconds?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Logout Token jti 外部存储(可选)。
|
|
110
|
+
* 默认使用进程内 LRU 缓存(重启即清空、多实例不共享);
|
|
111
|
+
* 多实例部署时应注入共享存储(如 Redis 实现)以防跨实例重放。
|
|
112
|
+
*/
|
|
113
|
+
logoutJtiStore?: LogoutJtiStore;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Logout Token jti 防重放存储接口(可注入 Redis 等共享存储实现)。
|
|
117
|
+
* has/add 均支持同步或异步返回。
|
|
118
|
+
*/
|
|
119
|
+
interface LogoutJtiStore {
|
|
120
|
+
/** 判断 jti 是否已处理过 */
|
|
121
|
+
has(key: string): boolean | Promise<boolean>;
|
|
122
|
+
/** 记录已处理的 jti,ttlSeconds 后过期 */
|
|
123
|
+
add(key: string, ttlSeconds: number): void | Promise<void>;
|
|
74
124
|
}
|
|
75
125
|
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
126
|
sub: string;
|
|
@@ -114,6 +164,7 @@ interface LogoutTokenPayload {
|
|
|
114
164
|
interface IntrospectResponse {
|
|
115
165
|
active: boolean;
|
|
116
166
|
sub?: string;
|
|
167
|
+
aud?: string | string[];
|
|
117
168
|
client_id?: string;
|
|
118
169
|
scope?: string;
|
|
119
170
|
exp?: number;
|
|
@@ -147,12 +198,27 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
147
198
|
* 1. type === "logout_token"
|
|
148
199
|
* 2. iss 匹配配置的 issuer
|
|
149
200
|
* 3. aud 包含当前 client_id
|
|
150
|
-
* 4.
|
|
201
|
+
* 4. exp 存在(规范要求)
|
|
202
|
+
* 5. events 包含 backchannel-logout 事件,且事件值为对象
|
|
151
203
|
*
|
|
152
204
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
153
205
|
*/
|
|
154
206
|
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
207
|
};
|
|
208
|
+
/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
|
|
209
|
+
interface SsoMiddlewareRequest {
|
|
210
|
+
headers?: Record<string, string | undefined>;
|
|
211
|
+
/** 验证通过后由中间件挂载的用户信息 */
|
|
212
|
+
user?: VerifiedTokenPayload;
|
|
213
|
+
[key: string]: unknown;
|
|
214
|
+
}
|
|
215
|
+
/** 框架无关的最小中间件响应类型 */
|
|
216
|
+
interface SsoMiddlewareResponse {
|
|
217
|
+
status?: (code: number) => {
|
|
218
|
+
json: (body: unknown) => unknown;
|
|
219
|
+
};
|
|
220
|
+
[key: string]: unknown;
|
|
221
|
+
}
|
|
156
222
|
/**
|
|
157
223
|
* Express/Next.js 兼容中间件
|
|
158
224
|
*
|
|
@@ -177,7 +243,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
177
243
|
* }
|
|
178
244
|
* ```
|
|
179
245
|
*/
|
|
180
|
-
declare function ssoMiddleware(options: SsoVerifierOptions): (req:
|
|
246
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: (err?: unknown) => void) => Promise<void>;
|
|
181
247
|
/**
|
|
182
248
|
* 创建 Logout Token 专用验证器
|
|
183
249
|
*
|
|
@@ -214,4 +280,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
|
214
280
|
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
281
|
};
|
|
216
282
|
|
|
217
|
-
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
|
283
|
+
export { type LogoutJtiStore, type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.js
CHANGED
|
@@ -48,7 +48,13 @@ function createTokenVerifier(options) {
|
|
|
48
48
|
accessTokenPublicKey,
|
|
49
49
|
jwksUri,
|
|
50
50
|
logoutTokenSecret,
|
|
51
|
-
|
|
51
|
+
logoutTokenPublicKey,
|
|
52
|
+
introspectCacheTtl = 30 * 1e3,
|
|
53
|
+
introspectTimeoutMs = 10 * 1e3,
|
|
54
|
+
introspectNegativeCacheTtl = 5 * 1e3,
|
|
55
|
+
introspectRetries = 1,
|
|
56
|
+
clockToleranceSeconds = 60,
|
|
57
|
+
logoutJtiStore
|
|
52
58
|
} = options;
|
|
53
59
|
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
54
60
|
let _jwksKeySet = null;
|
|
@@ -79,6 +85,31 @@ function createTokenVerifier(options) {
|
|
|
79
85
|
}
|
|
80
86
|
return _rs256PublicKey;
|
|
81
87
|
}
|
|
88
|
+
let _logoutRs256PublicKey = null;
|
|
89
|
+
let _logoutRs256KeyInitialized = false;
|
|
90
|
+
async function getLogoutRS256PublicKey() {
|
|
91
|
+
if (!_logoutRs256KeyInitialized) {
|
|
92
|
+
_logoutRs256KeyInitialized = true;
|
|
93
|
+
if (!logoutTokenPublicKey) {
|
|
94
|
+
_logoutRs256PublicKey = null;
|
|
95
|
+
} else {
|
|
96
|
+
try {
|
|
97
|
+
_logoutRs256PublicKey = await (0, import_jose.importSPKI)(logoutTokenPublicKey, "RS256");
|
|
98
|
+
} catch {
|
|
99
|
+
_logoutRs256PublicKey = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return _logoutRs256PublicKey;
|
|
104
|
+
}
|
|
105
|
+
function matchesAudience(data) {
|
|
106
|
+
const aud = data.aud;
|
|
107
|
+
if (typeof aud === "string") return aud === audience;
|
|
108
|
+
if (Array.isArray(aud)) return aud.includes(audience);
|
|
109
|
+
if (typeof data.client_id === "string") return data.client_id === audience;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const inflightIntrospects = /* @__PURE__ */ new Map();
|
|
82
113
|
async function introspect(token) {
|
|
83
114
|
if (!introspectionEndpoint || !clientId) {
|
|
84
115
|
return null;
|
|
@@ -87,34 +118,73 @@ function createTokenVerifier(options) {
|
|
|
87
118
|
if (cached) {
|
|
88
119
|
return { active: cached.active, ...cached.payload || {} };
|
|
89
120
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
121
|
+
const inflight = inflightIntrospects.get(token);
|
|
122
|
+
if (inflight) {
|
|
123
|
+
return inflight;
|
|
124
|
+
}
|
|
125
|
+
const promise = doIntrospect(token).finally(() => {
|
|
126
|
+
inflightIntrospects.delete(token);
|
|
127
|
+
});
|
|
128
|
+
inflightIntrospects.set(token, promise);
|
|
129
|
+
return promise;
|
|
130
|
+
}
|
|
131
|
+
async function doIntrospect(token) {
|
|
132
|
+
const params = new URLSearchParams({ token, client_id: clientId });
|
|
133
|
+
if (clientSecret) {
|
|
134
|
+
params.set("client_secret", clientSecret);
|
|
135
|
+
}
|
|
136
|
+
let data = null;
|
|
137
|
+
for (let attempt = 0; ; attempt++) {
|
|
138
|
+
if (attempt > 0) {
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
|
|
94
140
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
aud: audience,
|
|
107
|
-
iss: issuer,
|
|
108
|
-
client_id: data.client_id,
|
|
109
|
-
scope: data.scope,
|
|
110
|
-
exp: data.exp
|
|
111
|
-
};
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await fetch(introspectionEndpoint, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
146
|
+
body: params,
|
|
147
|
+
signal: AbortSignal.timeout(introspectTimeoutMs)
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
if (attempt < introspectRetries) continue;
|
|
151
|
+
return null;
|
|
112
152
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
if (response.status >= 500 && attempt < introspectRetries) continue;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
data = await response.json();
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (!data) return null;
|
|
161
|
+
if (data.active && !matchesAudience(data)) {
|
|
162
|
+
if (introspectNegativeCacheTtl > 0) {
|
|
163
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
164
|
+
ttl: introspectNegativeCacheTtl
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { active: false };
|
|
117
168
|
}
|
|
169
|
+
let payload = null;
|
|
170
|
+
if (data.active && data.sub) {
|
|
171
|
+
payload = {
|
|
172
|
+
sub: data.sub,
|
|
173
|
+
aud: audience,
|
|
174
|
+
iss: issuer,
|
|
175
|
+
client_id: data.client_id,
|
|
176
|
+
scope: data.scope,
|
|
177
|
+
exp: data.exp
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (data.active) {
|
|
181
|
+
introspectCache.set(token, { active: true, payload });
|
|
182
|
+
} else if (introspectNegativeCacheTtl > 0) {
|
|
183
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
184
|
+
ttl: introspectNegativeCacheTtl
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return data;
|
|
118
188
|
}
|
|
119
189
|
async function verifyLocally(token) {
|
|
120
190
|
if (!accessTokenSecret) return null;
|
|
@@ -122,7 +192,9 @@ function createTokenVerifier(options) {
|
|
|
122
192
|
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
123
193
|
const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
|
|
124
194
|
issuer,
|
|
125
|
-
audience
|
|
195
|
+
audience,
|
|
196
|
+
algorithms: ["HS256"],
|
|
197
|
+
clockTolerance: clockToleranceSeconds
|
|
126
198
|
});
|
|
127
199
|
if (payload.type !== "access_token") {
|
|
128
200
|
return null;
|
|
@@ -142,7 +214,8 @@ function createTokenVerifier(options) {
|
|
|
142
214
|
const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
|
|
143
215
|
issuer,
|
|
144
216
|
audience,
|
|
145
|
-
algorithms: ["RS256"]
|
|
217
|
+
algorithms: ["RS256"],
|
|
218
|
+
clockTolerance: clockToleranceSeconds
|
|
146
219
|
});
|
|
147
220
|
if (payload.type !== "access_token") return null;
|
|
148
221
|
return payload;
|
|
@@ -152,7 +225,8 @@ function createTokenVerifier(options) {
|
|
|
152
225
|
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
|
|
153
226
|
issuer,
|
|
154
227
|
audience,
|
|
155
|
-
algorithms: ["RS256"]
|
|
228
|
+
algorithms: ["RS256"],
|
|
229
|
+
clockTolerance: clockToleranceSeconds
|
|
156
230
|
});
|
|
157
231
|
if (payload.type !== "access_token") return null;
|
|
158
232
|
return payload;
|
|
@@ -181,9 +255,9 @@ function createTokenVerifier(options) {
|
|
|
181
255
|
const rs256Result = await verifyWithRS256(token);
|
|
182
256
|
if (rs256Result) return rs256Result;
|
|
183
257
|
const result = await introspect(token);
|
|
184
|
-
if (!result?.active) return null;
|
|
258
|
+
if (!result?.active || !result.sub) return null;
|
|
185
259
|
return {
|
|
186
|
-
sub: result.sub
|
|
260
|
+
sub: result.sub,
|
|
187
261
|
aud: audience,
|
|
188
262
|
iss: issuer,
|
|
189
263
|
client_id: result.client_id,
|
|
@@ -209,39 +283,97 @@ function createTokenVerifier(options) {
|
|
|
209
283
|
* 1. type === "logout_token"
|
|
210
284
|
* 2. iss 匹配配置的 issuer
|
|
211
285
|
* 3. aud 包含当前 client_id
|
|
212
|
-
* 4.
|
|
286
|
+
* 4. exp 存在(规范要求)
|
|
287
|
+
* 5. events 包含 backchannel-logout 事件,且事件值为对象
|
|
213
288
|
*
|
|
214
289
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
215
290
|
*/
|
|
216
291
|
async verifyLogoutToken(token) {
|
|
217
|
-
const
|
|
218
|
-
if (!secret) return null;
|
|
219
|
-
try {
|
|
220
|
-
const key = new TextEncoder().encode(secret);
|
|
221
|
-
const { payload } = await (0, import_jose.jwtVerify)(token, key, {
|
|
222
|
-
issuer,
|
|
223
|
-
audience,
|
|
224
|
-
algorithms: ["HS256"]
|
|
225
|
-
});
|
|
292
|
+
const validateLogoutPayload = async (payload) => {
|
|
226
293
|
if (payload.type !== "logout_token") {
|
|
227
294
|
return null;
|
|
228
295
|
}
|
|
296
|
+
if (typeof payload.exp !== "number") {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
229
299
|
const events = payload.events;
|
|
230
|
-
|
|
300
|
+
const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
|
|
301
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
231
302
|
return null;
|
|
232
303
|
}
|
|
233
304
|
const jti = payload.jti;
|
|
234
305
|
if (!jti || typeof jti !== "string") {
|
|
235
306
|
return null;
|
|
236
307
|
}
|
|
237
|
-
|
|
238
|
-
|
|
308
|
+
const iss = payload.iss || issuer;
|
|
309
|
+
const jtiKey = `${iss}:${jti}`;
|
|
310
|
+
if (logoutJtiStore) {
|
|
311
|
+
if (await logoutJtiStore.has(jtiKey)) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
await logoutJtiStore.add(jtiKey, 10 * 60);
|
|
315
|
+
} else {
|
|
316
|
+
if (processedLogoutJtis.has(jtiKey)) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
processedLogoutJtis.set(jtiKey, Date.now());
|
|
239
320
|
}
|
|
240
|
-
processedLogoutJtis.set(jti, Date.now());
|
|
241
321
|
return payload;
|
|
322
|
+
};
|
|
323
|
+
let alg;
|
|
324
|
+
try {
|
|
325
|
+
alg = (0, import_jose.decodeProtectedHeader)(token).alg;
|
|
242
326
|
} catch {
|
|
243
327
|
return null;
|
|
244
328
|
}
|
|
329
|
+
if (alg === "RS256") {
|
|
330
|
+
const directKey = await getLogoutRS256PublicKey();
|
|
331
|
+
if (directKey) {
|
|
332
|
+
try {
|
|
333
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
|
|
334
|
+
issuer,
|
|
335
|
+
audience,
|
|
336
|
+
algorithms: ["RS256"],
|
|
337
|
+
clockTolerance: clockToleranceSeconds
|
|
338
|
+
});
|
|
339
|
+
return await validateLogoutPayload(payload);
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const jwks = getJwksKeySet();
|
|
345
|
+
if (jwks) {
|
|
346
|
+
try {
|
|
347
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
|
|
348
|
+
issuer,
|
|
349
|
+
audience,
|
|
350
|
+
algorithms: ["RS256"],
|
|
351
|
+
clockTolerance: clockToleranceSeconds
|
|
352
|
+
});
|
|
353
|
+
return await validateLogoutPayload(payload);
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (alg === "HS256") {
|
|
361
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
362
|
+
if (!secret) return null;
|
|
363
|
+
try {
|
|
364
|
+
const key = new TextEncoder().encode(secret);
|
|
365
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, key, {
|
|
366
|
+
issuer,
|
|
367
|
+
audience,
|
|
368
|
+
algorithms: ["HS256"],
|
|
369
|
+
clockTolerance: clockToleranceSeconds
|
|
370
|
+
});
|
|
371
|
+
return await validateLogoutPayload(payload);
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
245
377
|
}
|
|
246
378
|
};
|
|
247
379
|
}
|
|
@@ -253,14 +385,18 @@ function ssoMiddleware(options) {
|
|
|
253
385
|
if (!token) {
|
|
254
386
|
if (res.status) {
|
|
255
387
|
res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
|
|
388
|
+
return;
|
|
256
389
|
}
|
|
390
|
+
next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
|
|
257
391
|
return;
|
|
258
392
|
}
|
|
259
393
|
const payload = await verifier.verify(token);
|
|
260
394
|
if (!payload) {
|
|
261
395
|
if (res.status) {
|
|
262
396
|
res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
|
|
397
|
+
return;
|
|
263
398
|
}
|
|
399
|
+
next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
|
|
264
400
|
return;
|
|
265
401
|
}
|
|
266
402
|
req.user = payload;
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
jwtVerify,
|
|
4
|
+
importSPKI,
|
|
5
|
+
createRemoteJWKSet,
|
|
6
|
+
decodeProtectedHeader
|
|
7
|
+
} from "jose";
|
|
3
8
|
import { LRUCache } from "lru-cache";
|
|
4
9
|
function createIntrospectCache(ttlMs) {
|
|
5
10
|
return new LRUCache({
|
|
@@ -22,7 +27,13 @@ function createTokenVerifier(options) {
|
|
|
22
27
|
accessTokenPublicKey,
|
|
23
28
|
jwksUri,
|
|
24
29
|
logoutTokenSecret,
|
|
25
|
-
|
|
30
|
+
logoutTokenPublicKey,
|
|
31
|
+
introspectCacheTtl = 30 * 1e3,
|
|
32
|
+
introspectTimeoutMs = 10 * 1e3,
|
|
33
|
+
introspectNegativeCacheTtl = 5 * 1e3,
|
|
34
|
+
introspectRetries = 1,
|
|
35
|
+
clockToleranceSeconds = 60,
|
|
36
|
+
logoutJtiStore
|
|
26
37
|
} = options;
|
|
27
38
|
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
28
39
|
let _jwksKeySet = null;
|
|
@@ -53,6 +64,31 @@ function createTokenVerifier(options) {
|
|
|
53
64
|
}
|
|
54
65
|
return _rs256PublicKey;
|
|
55
66
|
}
|
|
67
|
+
let _logoutRs256PublicKey = null;
|
|
68
|
+
let _logoutRs256KeyInitialized = false;
|
|
69
|
+
async function getLogoutRS256PublicKey() {
|
|
70
|
+
if (!_logoutRs256KeyInitialized) {
|
|
71
|
+
_logoutRs256KeyInitialized = true;
|
|
72
|
+
if (!logoutTokenPublicKey) {
|
|
73
|
+
_logoutRs256PublicKey = null;
|
|
74
|
+
} else {
|
|
75
|
+
try {
|
|
76
|
+
_logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, "RS256");
|
|
77
|
+
} catch {
|
|
78
|
+
_logoutRs256PublicKey = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return _logoutRs256PublicKey;
|
|
83
|
+
}
|
|
84
|
+
function matchesAudience(data) {
|
|
85
|
+
const aud = data.aud;
|
|
86
|
+
if (typeof aud === "string") return aud === audience;
|
|
87
|
+
if (Array.isArray(aud)) return aud.includes(audience);
|
|
88
|
+
if (typeof data.client_id === "string") return data.client_id === audience;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const inflightIntrospects = /* @__PURE__ */ new Map();
|
|
56
92
|
async function introspect(token) {
|
|
57
93
|
if (!introspectionEndpoint || !clientId) {
|
|
58
94
|
return null;
|
|
@@ -61,34 +97,73 @@ function createTokenVerifier(options) {
|
|
|
61
97
|
if (cached) {
|
|
62
98
|
return { active: cached.active, ...cached.payload || {} };
|
|
63
99
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
const inflight = inflightIntrospects.get(token);
|
|
101
|
+
if (inflight) {
|
|
102
|
+
return inflight;
|
|
103
|
+
}
|
|
104
|
+
const promise = doIntrospect(token).finally(() => {
|
|
105
|
+
inflightIntrospects.delete(token);
|
|
106
|
+
});
|
|
107
|
+
inflightIntrospects.set(token, promise);
|
|
108
|
+
return promise;
|
|
109
|
+
}
|
|
110
|
+
async function doIntrospect(token) {
|
|
111
|
+
const params = new URLSearchParams({ token, client_id: clientId });
|
|
112
|
+
if (clientSecret) {
|
|
113
|
+
params.set("client_secret", clientSecret);
|
|
114
|
+
}
|
|
115
|
+
let data = null;
|
|
116
|
+
for (let attempt = 0; ; attempt++) {
|
|
117
|
+
if (attempt > 0) {
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
|
|
68
119
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
aud: audience,
|
|
81
|
-
iss: issuer,
|
|
82
|
-
client_id: data.client_id,
|
|
83
|
-
scope: data.scope,
|
|
84
|
-
exp: data.exp
|
|
85
|
-
};
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(introspectionEndpoint, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
125
|
+
body: params,
|
|
126
|
+
signal: AbortSignal.timeout(introspectTimeoutMs)
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
if (attempt < introspectRetries) continue;
|
|
130
|
+
return null;
|
|
86
131
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
if (response.status >= 500 && attempt < introspectRetries) continue;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
data = await response.json();
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (!data) return null;
|
|
140
|
+
if (data.active && !matchesAudience(data)) {
|
|
141
|
+
if (introspectNegativeCacheTtl > 0) {
|
|
142
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
143
|
+
ttl: introspectNegativeCacheTtl
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { active: false };
|
|
91
147
|
}
|
|
148
|
+
let payload = null;
|
|
149
|
+
if (data.active && data.sub) {
|
|
150
|
+
payload = {
|
|
151
|
+
sub: data.sub,
|
|
152
|
+
aud: audience,
|
|
153
|
+
iss: issuer,
|
|
154
|
+
client_id: data.client_id,
|
|
155
|
+
scope: data.scope,
|
|
156
|
+
exp: data.exp
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (data.active) {
|
|
160
|
+
introspectCache.set(token, { active: true, payload });
|
|
161
|
+
} else if (introspectNegativeCacheTtl > 0) {
|
|
162
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
163
|
+
ttl: introspectNegativeCacheTtl
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return data;
|
|
92
167
|
}
|
|
93
168
|
async function verifyLocally(token) {
|
|
94
169
|
if (!accessTokenSecret) return null;
|
|
@@ -96,7 +171,9 @@ function createTokenVerifier(options) {
|
|
|
96
171
|
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
97
172
|
const { payload } = await jwtVerify(token, secret, {
|
|
98
173
|
issuer,
|
|
99
|
-
audience
|
|
174
|
+
audience,
|
|
175
|
+
algorithms: ["HS256"],
|
|
176
|
+
clockTolerance: clockToleranceSeconds
|
|
100
177
|
});
|
|
101
178
|
if (payload.type !== "access_token") {
|
|
102
179
|
return null;
|
|
@@ -116,7 +193,8 @@ function createTokenVerifier(options) {
|
|
|
116
193
|
const { payload } = await jwtVerify(token, directKey, {
|
|
117
194
|
issuer,
|
|
118
195
|
audience,
|
|
119
|
-
algorithms: ["RS256"]
|
|
196
|
+
algorithms: ["RS256"],
|
|
197
|
+
clockTolerance: clockToleranceSeconds
|
|
120
198
|
});
|
|
121
199
|
if (payload.type !== "access_token") return null;
|
|
122
200
|
return payload;
|
|
@@ -126,7 +204,8 @@ function createTokenVerifier(options) {
|
|
|
126
204
|
const { payload } = await jwtVerify(token, jwks, {
|
|
127
205
|
issuer,
|
|
128
206
|
audience,
|
|
129
|
-
algorithms: ["RS256"]
|
|
207
|
+
algorithms: ["RS256"],
|
|
208
|
+
clockTolerance: clockToleranceSeconds
|
|
130
209
|
});
|
|
131
210
|
if (payload.type !== "access_token") return null;
|
|
132
211
|
return payload;
|
|
@@ -155,9 +234,9 @@ function createTokenVerifier(options) {
|
|
|
155
234
|
const rs256Result = await verifyWithRS256(token);
|
|
156
235
|
if (rs256Result) return rs256Result;
|
|
157
236
|
const result = await introspect(token);
|
|
158
|
-
if (!result?.active) return null;
|
|
237
|
+
if (!result?.active || !result.sub) return null;
|
|
159
238
|
return {
|
|
160
|
-
sub: result.sub
|
|
239
|
+
sub: result.sub,
|
|
161
240
|
aud: audience,
|
|
162
241
|
iss: issuer,
|
|
163
242
|
client_id: result.client_id,
|
|
@@ -183,39 +262,97 @@ function createTokenVerifier(options) {
|
|
|
183
262
|
* 1. type === "logout_token"
|
|
184
263
|
* 2. iss 匹配配置的 issuer
|
|
185
264
|
* 3. aud 包含当前 client_id
|
|
186
|
-
* 4.
|
|
265
|
+
* 4. exp 存在(规范要求)
|
|
266
|
+
* 5. events 包含 backchannel-logout 事件,且事件值为对象
|
|
187
267
|
*
|
|
188
268
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
189
269
|
*/
|
|
190
270
|
async verifyLogoutToken(token) {
|
|
191
|
-
const
|
|
192
|
-
if (!secret) return null;
|
|
193
|
-
try {
|
|
194
|
-
const key = new TextEncoder().encode(secret);
|
|
195
|
-
const { payload } = await jwtVerify(token, key, {
|
|
196
|
-
issuer,
|
|
197
|
-
audience,
|
|
198
|
-
algorithms: ["HS256"]
|
|
199
|
-
});
|
|
271
|
+
const validateLogoutPayload = async (payload) => {
|
|
200
272
|
if (payload.type !== "logout_token") {
|
|
201
273
|
return null;
|
|
202
274
|
}
|
|
275
|
+
if (typeof payload.exp !== "number") {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
203
278
|
const events = payload.events;
|
|
204
|
-
|
|
279
|
+
const logoutEvent = events?.["http://schemas.openid.net/event/backchannel-logout"];
|
|
280
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
205
281
|
return null;
|
|
206
282
|
}
|
|
207
283
|
const jti = payload.jti;
|
|
208
284
|
if (!jti || typeof jti !== "string") {
|
|
209
285
|
return null;
|
|
210
286
|
}
|
|
211
|
-
|
|
212
|
-
|
|
287
|
+
const iss = payload.iss || issuer;
|
|
288
|
+
const jtiKey = `${iss}:${jti}`;
|
|
289
|
+
if (logoutJtiStore) {
|
|
290
|
+
if (await logoutJtiStore.has(jtiKey)) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
await logoutJtiStore.add(jtiKey, 10 * 60);
|
|
294
|
+
} else {
|
|
295
|
+
if (processedLogoutJtis.has(jtiKey)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
processedLogoutJtis.set(jtiKey, Date.now());
|
|
213
299
|
}
|
|
214
|
-
processedLogoutJtis.set(jti, Date.now());
|
|
215
300
|
return payload;
|
|
301
|
+
};
|
|
302
|
+
let alg;
|
|
303
|
+
try {
|
|
304
|
+
alg = decodeProtectedHeader(token).alg;
|
|
216
305
|
} catch {
|
|
217
306
|
return null;
|
|
218
307
|
}
|
|
308
|
+
if (alg === "RS256") {
|
|
309
|
+
const directKey = await getLogoutRS256PublicKey();
|
|
310
|
+
if (directKey) {
|
|
311
|
+
try {
|
|
312
|
+
const { payload } = await jwtVerify(token, directKey, {
|
|
313
|
+
issuer,
|
|
314
|
+
audience,
|
|
315
|
+
algorithms: ["RS256"],
|
|
316
|
+
clockTolerance: clockToleranceSeconds
|
|
317
|
+
});
|
|
318
|
+
return await validateLogoutPayload(payload);
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const jwks = getJwksKeySet();
|
|
324
|
+
if (jwks) {
|
|
325
|
+
try {
|
|
326
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
327
|
+
issuer,
|
|
328
|
+
audience,
|
|
329
|
+
algorithms: ["RS256"],
|
|
330
|
+
clockTolerance: clockToleranceSeconds
|
|
331
|
+
});
|
|
332
|
+
return await validateLogoutPayload(payload);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
if (alg === "HS256") {
|
|
340
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
341
|
+
if (!secret) return null;
|
|
342
|
+
try {
|
|
343
|
+
const key = new TextEncoder().encode(secret);
|
|
344
|
+
const { payload } = await jwtVerify(token, key, {
|
|
345
|
+
issuer,
|
|
346
|
+
audience,
|
|
347
|
+
algorithms: ["HS256"],
|
|
348
|
+
clockTolerance: clockToleranceSeconds
|
|
349
|
+
});
|
|
350
|
+
return await validateLogoutPayload(payload);
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
219
356
|
}
|
|
220
357
|
};
|
|
221
358
|
}
|
|
@@ -227,14 +364,18 @@ function ssoMiddleware(options) {
|
|
|
227
364
|
if (!token) {
|
|
228
365
|
if (res.status) {
|
|
229
366
|
res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
|
|
367
|
+
return;
|
|
230
368
|
}
|
|
369
|
+
next(new Error("Unauthorized: \u8BF7\u63D0\u4F9B Bearer token"));
|
|
231
370
|
return;
|
|
232
371
|
}
|
|
233
372
|
const payload = await verifier.verify(token);
|
|
234
373
|
if (!payload) {
|
|
235
374
|
if (res.status) {
|
|
236
375
|
res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
|
|
376
|
+
return;
|
|
237
377
|
}
|
|
378
|
+
next(new Error("Unauthorized: Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F"));
|
|
238
379
|
return;
|
|
239
380
|
}
|
|
240
381
|
req.user = payload;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nihplod/sso-verify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
37
|
-
"url": "https://github.com/
|
|
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",
|