@nihplod/sso-verify 1.0.2 → 1.1.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 +39 -0
- package/dist/index.d.mts +36 -2
- package/dist/index.d.ts +36 -2
- package/dist/index.js +93 -22
- package/dist/index.mjs +99 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ npm install @nihplod/sso-verify
|
|
|
14
14
|
|
|
15
15
|
Suitable for all sub-projects; no signing keys required.
|
|
16
16
|
|
|
17
|
+
#### Confidential Client (server-side or BFF)
|
|
18
|
+
|
|
17
19
|
```typescript
|
|
18
20
|
import { createTokenVerifier } from "@nihplod/sso-verify";
|
|
19
21
|
|
|
@@ -31,6 +33,23 @@ if (payload) {
|
|
|
31
33
|
}
|
|
32
34
|
```
|
|
33
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
|
+
|
|
34
53
|
### Method 2: Local JWT Verification (Internal Confidential Clients Only)
|
|
35
54
|
|
|
36
55
|
Only use this when the sub-project shares `JWT_ACCESS_SECRET` with the main site.
|
|
@@ -70,3 +89,23 @@ After successful verification, the token payload is attached to `req.user`.
|
|
|
70
89
|
- The main site currently uses HS256 symmetric signing by default. For external sub-projects, we recommend verifying access tokens via **Introspection**.
|
|
71
90
|
- If your sub-project is an internal service and already shares the RS256 public key with the main site, you can configure `accessTokenPublicKey` or `jwksUri` for local JWT verification.
|
|
72
91
|
- **Never distribute `JWT_ACCESS_SECRET` (the HS256 symmetric key) to external sub-projects.**
|
|
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
|
+
| `accessTokenPublicKey` | — | RS256 public key (PEM) for local access token verification |
|
|
101
|
+
| `jwksUri` | — | JWKS endpoint URL; public keys are matched by `kid` |
|
|
102
|
+
| `logoutTokenPublicKey` | — | RS256 public key (PEM) for back-channel `logout_token` verification |
|
|
103
|
+
| `logoutTokenSecret` | — | HS256 secret for local `logout_token` verification |
|
|
104
|
+
|
|
105
|
+
### Revocation latency trade-off
|
|
106
|
+
|
|
107
|
+
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
|
+
|
|
109
|
+
### Logout token verification
|
|
110
|
+
|
|
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. Verified `jti`s are replay-guarded per issuer for 10 minutes.
|
package/dist/index.d.mts
CHANGED
|
@@ -53,6 +53,17 @@ interface SsoVerifierOptions {
|
|
|
53
53
|
accessTokenSecret?: string;
|
|
54
54
|
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
55
|
introspectCacheTtl?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Introspection 请求超时时间(毫秒),默认 10 秒。
|
|
58
|
+
* 超时按验证失败处理(返回 null)。
|
|
59
|
+
*/
|
|
60
|
+
introspectTimeoutMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
|
|
63
|
+
* 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
|
|
64
|
+
* 设为 0 表示不缓存 active:false 结果。
|
|
65
|
+
*/
|
|
66
|
+
introspectNegativeCacheTtl?: number;
|
|
56
67
|
/**
|
|
57
68
|
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
69
|
* 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
|
|
@@ -71,6 +82,15 @@ interface SsoVerifierOptions {
|
|
|
71
82
|
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
83
|
*/
|
|
73
84
|
logoutTokenSecret?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Logout Token RS256 公钥(PEM 格式,可选)。
|
|
87
|
+
* 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
|
|
88
|
+
* 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
|
|
89
|
+
* 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
|
|
90
|
+
* RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
|
|
91
|
+
* 不会静默回退到 HS256。
|
|
92
|
+
*/
|
|
93
|
+
logoutTokenPublicKey?: string;
|
|
74
94
|
}
|
|
75
95
|
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
96
|
sub: string;
|
|
@@ -153,6 +173,20 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
153
173
|
*/
|
|
154
174
|
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
175
|
};
|
|
176
|
+
/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
|
|
177
|
+
interface SsoMiddlewareRequest {
|
|
178
|
+
headers?: Record<string, string | undefined>;
|
|
179
|
+
/** 验证通过后由中间件挂载的用户信息 */
|
|
180
|
+
user?: VerifiedTokenPayload;
|
|
181
|
+
[key: string]: unknown;
|
|
182
|
+
}
|
|
183
|
+
/** 框架无关的最小中间件响应类型 */
|
|
184
|
+
interface SsoMiddlewareResponse {
|
|
185
|
+
status?: (code: number) => {
|
|
186
|
+
json: (body: unknown) => unknown;
|
|
187
|
+
};
|
|
188
|
+
[key: string]: unknown;
|
|
189
|
+
}
|
|
156
190
|
/**
|
|
157
191
|
* Express/Next.js 兼容中间件
|
|
158
192
|
*
|
|
@@ -177,7 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
177
211
|
* }
|
|
178
212
|
* ```
|
|
179
213
|
*/
|
|
180
|
-
declare function ssoMiddleware(options: SsoVerifierOptions): (req:
|
|
214
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
|
|
181
215
|
/**
|
|
182
216
|
* 创建 Logout Token 专用验证器
|
|
183
217
|
*
|
|
@@ -214,4 +248,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
|
214
248
|
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
249
|
};
|
|
216
250
|
|
|
217
|
-
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
|
251
|
+
export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,17 @@ interface SsoVerifierOptions {
|
|
|
53
53
|
accessTokenSecret?: string;
|
|
54
54
|
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
55
|
introspectCacheTtl?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Introspection 请求超时时间(毫秒),默认 10 秒。
|
|
58
|
+
* 超时按验证失败处理(返回 null)。
|
|
59
|
+
*/
|
|
60
|
+
introspectTimeoutMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Introspection 返回 active:false(已撤销/无效)结果的缓存 TTL(毫秒),默认 5 秒。
|
|
63
|
+
* 明显短于 active:true 的 TTL,以降低 token 撤销后的生效延迟。
|
|
64
|
+
* 设为 0 表示不缓存 active:false 结果。
|
|
65
|
+
*/
|
|
66
|
+
introspectNegativeCacheTtl?: number;
|
|
56
67
|
/**
|
|
57
68
|
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
69
|
* 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
|
|
@@ -71,6 +82,15 @@ interface SsoVerifierOptions {
|
|
|
71
82
|
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
83
|
*/
|
|
73
84
|
logoutTokenSecret?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Logout Token RS256 公钥(PEM 格式,可选)。
|
|
87
|
+
* 主站 logout_token 使用独立密钥对(kid: logout-token-rs256-v1)签名,
|
|
88
|
+
* 与 access token 密钥不同,因此不能使用 accessTokenPublicKey 验证。
|
|
89
|
+
* 若未提供但配置了 jwksUri,将通过 JWKS 按 kid 匹配获取对应公钥。
|
|
90
|
+
* RS256 签名的 logout_token 在无任何可用公钥时验证失败(返回 null),
|
|
91
|
+
* 不会静默回退到 HS256。
|
|
92
|
+
*/
|
|
93
|
+
logoutTokenPublicKey?: string;
|
|
74
94
|
}
|
|
75
95
|
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
96
|
sub: string;
|
|
@@ -153,6 +173,20 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
153
173
|
*/
|
|
154
174
|
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
175
|
};
|
|
176
|
+
/** 框架无关的最小中间件请求类型(兼容 Express/Connect 风格) */
|
|
177
|
+
interface SsoMiddlewareRequest {
|
|
178
|
+
headers?: Record<string, string | undefined>;
|
|
179
|
+
/** 验证通过后由中间件挂载的用户信息 */
|
|
180
|
+
user?: VerifiedTokenPayload;
|
|
181
|
+
[key: string]: unknown;
|
|
182
|
+
}
|
|
183
|
+
/** 框架无关的最小中间件响应类型 */
|
|
184
|
+
interface SsoMiddlewareResponse {
|
|
185
|
+
status?: (code: number) => {
|
|
186
|
+
json: (body: unknown) => unknown;
|
|
187
|
+
};
|
|
188
|
+
[key: string]: unknown;
|
|
189
|
+
}
|
|
156
190
|
/**
|
|
157
191
|
* Express/Next.js 兼容中间件
|
|
158
192
|
*
|
|
@@ -177,7 +211,7 @@ declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
|
177
211
|
* }
|
|
178
212
|
* ```
|
|
179
213
|
*/
|
|
180
|
-
declare function ssoMiddleware(options: SsoVerifierOptions): (req:
|
|
214
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: SsoMiddlewareRequest, res: SsoMiddlewareResponse, next: () => void) => Promise<void>;
|
|
181
215
|
/**
|
|
182
216
|
* 创建 Logout Token 专用验证器
|
|
183
217
|
*
|
|
@@ -214,4 +248,4 @@ declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
|
214
248
|
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
249
|
};
|
|
216
250
|
|
|
217
|
-
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
|
251
|
+
export { type LogoutTokenPayload, type SsoMiddlewareRequest, type SsoMiddlewareResponse, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.js
CHANGED
|
@@ -48,7 +48,10 @@ 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
|
|
52
55
|
} = options;
|
|
53
56
|
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
54
57
|
let _jwksKeySet = null;
|
|
@@ -79,8 +82,25 @@ function createTokenVerifier(options) {
|
|
|
79
82
|
}
|
|
80
83
|
return _rs256PublicKey;
|
|
81
84
|
}
|
|
85
|
+
let _logoutRs256PublicKey = null;
|
|
86
|
+
let _logoutRs256KeyInitialized = false;
|
|
87
|
+
async function getLogoutRS256PublicKey() {
|
|
88
|
+
if (!_logoutRs256KeyInitialized) {
|
|
89
|
+
_logoutRs256KeyInitialized = true;
|
|
90
|
+
if (!logoutTokenPublicKey) {
|
|
91
|
+
_logoutRs256PublicKey = null;
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
_logoutRs256PublicKey = await (0, import_jose.importSPKI)(logoutTokenPublicKey, "RS256");
|
|
95
|
+
} catch {
|
|
96
|
+
_logoutRs256PublicKey = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return _logoutRs256PublicKey;
|
|
101
|
+
}
|
|
82
102
|
async function introspect(token) {
|
|
83
|
-
if (!introspectionEndpoint || !clientId
|
|
103
|
+
if (!introspectionEndpoint || !clientId) {
|
|
84
104
|
return null;
|
|
85
105
|
}
|
|
86
106
|
const cached = introspectCache.get(token);
|
|
@@ -88,14 +108,15 @@ function createTokenVerifier(options) {
|
|
|
88
108
|
return { active: cached.active, ...cached.payload || {} };
|
|
89
109
|
}
|
|
90
110
|
try {
|
|
111
|
+
const params = new URLSearchParams({ token, client_id: clientId });
|
|
112
|
+
if (clientSecret) {
|
|
113
|
+
params.set("client_secret", clientSecret);
|
|
114
|
+
}
|
|
91
115
|
const response = await fetch(introspectionEndpoint, {
|
|
92
116
|
method: "POST",
|
|
93
117
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
|
-
body:
|
|
95
|
-
|
|
96
|
-
client_id: clientId,
|
|
97
|
-
client_secret: clientSecret
|
|
98
|
-
})
|
|
118
|
+
body: params,
|
|
119
|
+
signal: AbortSignal.timeout(introspectTimeoutMs)
|
|
99
120
|
});
|
|
100
121
|
if (!response.ok) return null;
|
|
101
122
|
const data = await response.json();
|
|
@@ -110,7 +131,13 @@ function createTokenVerifier(options) {
|
|
|
110
131
|
exp: data.exp
|
|
111
132
|
};
|
|
112
133
|
}
|
|
113
|
-
|
|
134
|
+
if (data.active) {
|
|
135
|
+
introspectCache.set(token, { active: true, payload });
|
|
136
|
+
} else if (introspectNegativeCacheTtl > 0) {
|
|
137
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
138
|
+
ttl: introspectNegativeCacheTtl
|
|
139
|
+
});
|
|
140
|
+
}
|
|
114
141
|
return data;
|
|
115
142
|
} catch {
|
|
116
143
|
return null;
|
|
@@ -122,7 +149,8 @@ function createTokenVerifier(options) {
|
|
|
122
149
|
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
123
150
|
const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
|
|
124
151
|
issuer,
|
|
125
|
-
audience
|
|
152
|
+
audience,
|
|
153
|
+
algorithms: ["HS256"]
|
|
126
154
|
});
|
|
127
155
|
if (payload.type !== "access_token") {
|
|
128
156
|
return null;
|
|
@@ -181,9 +209,9 @@ function createTokenVerifier(options) {
|
|
|
181
209
|
const rs256Result = await verifyWithRS256(token);
|
|
182
210
|
if (rs256Result) return rs256Result;
|
|
183
211
|
const result = await introspect(token);
|
|
184
|
-
if (!result?.active) return null;
|
|
212
|
+
if (!result?.active || !result.sub) return null;
|
|
185
213
|
return {
|
|
186
|
-
sub: result.sub
|
|
214
|
+
sub: result.sub,
|
|
187
215
|
aud: audience,
|
|
188
216
|
iss: issuer,
|
|
189
217
|
client_id: result.client_id,
|
|
@@ -214,15 +242,7 @@ function createTokenVerifier(options) {
|
|
|
214
242
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
215
243
|
*/
|
|
216
244
|
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
|
-
});
|
|
245
|
+
const validateLogoutPayload = (payload) => {
|
|
226
246
|
if (payload.type !== "logout_token") {
|
|
227
247
|
return null;
|
|
228
248
|
}
|
|
@@ -234,14 +254,65 @@ function createTokenVerifier(options) {
|
|
|
234
254
|
if (!jti || typeof jti !== "string") {
|
|
235
255
|
return null;
|
|
236
256
|
}
|
|
237
|
-
|
|
257
|
+
const iss = payload.iss || issuer;
|
|
258
|
+
const jtiKey = `${iss}:${jti}`;
|
|
259
|
+
if (processedLogoutJtis.has(jtiKey)) {
|
|
238
260
|
return null;
|
|
239
261
|
}
|
|
240
|
-
processedLogoutJtis.set(
|
|
262
|
+
processedLogoutJtis.set(jtiKey, Date.now());
|
|
241
263
|
return payload;
|
|
264
|
+
};
|
|
265
|
+
let alg;
|
|
266
|
+
try {
|
|
267
|
+
alg = (0, import_jose.decodeProtectedHeader)(token).alg;
|
|
242
268
|
} catch {
|
|
243
269
|
return null;
|
|
244
270
|
}
|
|
271
|
+
if (alg === "RS256") {
|
|
272
|
+
const directKey = await getLogoutRS256PublicKey();
|
|
273
|
+
if (directKey) {
|
|
274
|
+
try {
|
|
275
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
|
|
276
|
+
issuer,
|
|
277
|
+
audience,
|
|
278
|
+
algorithms: ["RS256"]
|
|
279
|
+
});
|
|
280
|
+
return validateLogoutPayload(payload);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const jwks = getJwksKeySet();
|
|
286
|
+
if (jwks) {
|
|
287
|
+
try {
|
|
288
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
|
|
289
|
+
issuer,
|
|
290
|
+
audience,
|
|
291
|
+
algorithms: ["RS256"]
|
|
292
|
+
});
|
|
293
|
+
return validateLogoutPayload(payload);
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (alg === "HS256") {
|
|
301
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
302
|
+
if (!secret) return null;
|
|
303
|
+
try {
|
|
304
|
+
const key = new TextEncoder().encode(secret);
|
|
305
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, key, {
|
|
306
|
+
issuer,
|
|
307
|
+
audience,
|
|
308
|
+
algorithms: ["HS256"]
|
|
309
|
+
});
|
|
310
|
+
return validateLogoutPayload(payload);
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
245
316
|
}
|
|
246
317
|
};
|
|
247
318
|
}
|
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,10 @@ 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
|
|
26
34
|
} = options;
|
|
27
35
|
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
28
36
|
let _jwksKeySet = null;
|
|
@@ -53,8 +61,25 @@ function createTokenVerifier(options) {
|
|
|
53
61
|
}
|
|
54
62
|
return _rs256PublicKey;
|
|
55
63
|
}
|
|
64
|
+
let _logoutRs256PublicKey = null;
|
|
65
|
+
let _logoutRs256KeyInitialized = false;
|
|
66
|
+
async function getLogoutRS256PublicKey() {
|
|
67
|
+
if (!_logoutRs256KeyInitialized) {
|
|
68
|
+
_logoutRs256KeyInitialized = true;
|
|
69
|
+
if (!logoutTokenPublicKey) {
|
|
70
|
+
_logoutRs256PublicKey = null;
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
_logoutRs256PublicKey = await importSPKI(logoutTokenPublicKey, "RS256");
|
|
74
|
+
} catch {
|
|
75
|
+
_logoutRs256PublicKey = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return _logoutRs256PublicKey;
|
|
80
|
+
}
|
|
56
81
|
async function introspect(token) {
|
|
57
|
-
if (!introspectionEndpoint || !clientId
|
|
82
|
+
if (!introspectionEndpoint || !clientId) {
|
|
58
83
|
return null;
|
|
59
84
|
}
|
|
60
85
|
const cached = introspectCache.get(token);
|
|
@@ -62,14 +87,15 @@ function createTokenVerifier(options) {
|
|
|
62
87
|
return { active: cached.active, ...cached.payload || {} };
|
|
63
88
|
}
|
|
64
89
|
try {
|
|
90
|
+
const params = new URLSearchParams({ token, client_id: clientId });
|
|
91
|
+
if (clientSecret) {
|
|
92
|
+
params.set("client_secret", clientSecret);
|
|
93
|
+
}
|
|
65
94
|
const response = await fetch(introspectionEndpoint, {
|
|
66
95
|
method: "POST",
|
|
67
96
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
68
|
-
body:
|
|
69
|
-
|
|
70
|
-
client_id: clientId,
|
|
71
|
-
client_secret: clientSecret
|
|
72
|
-
})
|
|
97
|
+
body: params,
|
|
98
|
+
signal: AbortSignal.timeout(introspectTimeoutMs)
|
|
73
99
|
});
|
|
74
100
|
if (!response.ok) return null;
|
|
75
101
|
const data = await response.json();
|
|
@@ -84,7 +110,13 @@ function createTokenVerifier(options) {
|
|
|
84
110
|
exp: data.exp
|
|
85
111
|
};
|
|
86
112
|
}
|
|
87
|
-
|
|
113
|
+
if (data.active) {
|
|
114
|
+
introspectCache.set(token, { active: true, payload });
|
|
115
|
+
} else if (introspectNegativeCacheTtl > 0) {
|
|
116
|
+
introspectCache.set(token, { active: false, payload: null }, {
|
|
117
|
+
ttl: introspectNegativeCacheTtl
|
|
118
|
+
});
|
|
119
|
+
}
|
|
88
120
|
return data;
|
|
89
121
|
} catch {
|
|
90
122
|
return null;
|
|
@@ -96,7 +128,8 @@ function createTokenVerifier(options) {
|
|
|
96
128
|
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
97
129
|
const { payload } = await jwtVerify(token, secret, {
|
|
98
130
|
issuer,
|
|
99
|
-
audience
|
|
131
|
+
audience,
|
|
132
|
+
algorithms: ["HS256"]
|
|
100
133
|
});
|
|
101
134
|
if (payload.type !== "access_token") {
|
|
102
135
|
return null;
|
|
@@ -155,9 +188,9 @@ function createTokenVerifier(options) {
|
|
|
155
188
|
const rs256Result = await verifyWithRS256(token);
|
|
156
189
|
if (rs256Result) return rs256Result;
|
|
157
190
|
const result = await introspect(token);
|
|
158
|
-
if (!result?.active) return null;
|
|
191
|
+
if (!result?.active || !result.sub) return null;
|
|
159
192
|
return {
|
|
160
|
-
sub: result.sub
|
|
193
|
+
sub: result.sub,
|
|
161
194
|
aud: audience,
|
|
162
195
|
iss: issuer,
|
|
163
196
|
client_id: result.client_id,
|
|
@@ -188,15 +221,7 @@ function createTokenVerifier(options) {
|
|
|
188
221
|
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
189
222
|
*/
|
|
190
223
|
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
|
-
});
|
|
224
|
+
const validateLogoutPayload = (payload) => {
|
|
200
225
|
if (payload.type !== "logout_token") {
|
|
201
226
|
return null;
|
|
202
227
|
}
|
|
@@ -208,14 +233,65 @@ function createTokenVerifier(options) {
|
|
|
208
233
|
if (!jti || typeof jti !== "string") {
|
|
209
234
|
return null;
|
|
210
235
|
}
|
|
211
|
-
|
|
236
|
+
const iss = payload.iss || issuer;
|
|
237
|
+
const jtiKey = `${iss}:${jti}`;
|
|
238
|
+
if (processedLogoutJtis.has(jtiKey)) {
|
|
212
239
|
return null;
|
|
213
240
|
}
|
|
214
|
-
processedLogoutJtis.set(
|
|
241
|
+
processedLogoutJtis.set(jtiKey, Date.now());
|
|
215
242
|
return payload;
|
|
243
|
+
};
|
|
244
|
+
let alg;
|
|
245
|
+
try {
|
|
246
|
+
alg = decodeProtectedHeader(token).alg;
|
|
216
247
|
} catch {
|
|
217
248
|
return null;
|
|
218
249
|
}
|
|
250
|
+
if (alg === "RS256") {
|
|
251
|
+
const directKey = await getLogoutRS256PublicKey();
|
|
252
|
+
if (directKey) {
|
|
253
|
+
try {
|
|
254
|
+
const { payload } = await jwtVerify(token, directKey, {
|
|
255
|
+
issuer,
|
|
256
|
+
audience,
|
|
257
|
+
algorithms: ["RS256"]
|
|
258
|
+
});
|
|
259
|
+
return validateLogoutPayload(payload);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const jwks = getJwksKeySet();
|
|
265
|
+
if (jwks) {
|
|
266
|
+
try {
|
|
267
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
268
|
+
issuer,
|
|
269
|
+
audience,
|
|
270
|
+
algorithms: ["RS256"]
|
|
271
|
+
});
|
|
272
|
+
return validateLogoutPayload(payload);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (alg === "HS256") {
|
|
280
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
281
|
+
if (!secret) return null;
|
|
282
|
+
try {
|
|
283
|
+
const key = new TextEncoder().encode(secret);
|
|
284
|
+
const { payload } = await jwtVerify(token, key, {
|
|
285
|
+
issuer,
|
|
286
|
+
audience,
|
|
287
|
+
algorithms: ["HS256"]
|
|
288
|
+
});
|
|
289
|
+
return validateLogoutPayload(payload);
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
219
295
|
}
|
|
220
296
|
};
|
|
221
297
|
}
|