@nihplod/sso-verify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/index.d.mts +217 -0
- package/dist/index.d.ts +217 -0
- package/dist/index.js +286 -0
- package/dist/index.mjs +259 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @nihplod/sso-verify
|
|
2
|
+
|
|
3
|
+
NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @nihplod/sso-verify
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用方式
|
|
12
|
+
|
|
13
|
+
### 方式一:Introspection 验证(推荐)
|
|
14
|
+
|
|
15
|
+
适用于所有子项目,无需接触签名密钥。
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { createTokenVerifier } from "@nihplod/sso-verify";
|
|
19
|
+
|
|
20
|
+
const verifier = createTokenVerifier({
|
|
21
|
+
introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
22
|
+
clientId: "your-client-id",
|
|
23
|
+
clientSecret: "your-client-secret",
|
|
24
|
+
audience: "your-client-id",
|
|
25
|
+
issuer: "https://nihplod.cn",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const payload = await verifier.verify(token);
|
|
29
|
+
if (payload) {
|
|
30
|
+
console.log(payload.sub); // 用户 ID
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 方式二:本地 JWT 验证(仅内部 Confidential Client)
|
|
35
|
+
|
|
36
|
+
仅当子项目与主站共享 `JWT_ACCESS_SECRET` 时使用。
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { createTokenVerifier } from "@nihplod/sso-verify";
|
|
40
|
+
|
|
41
|
+
const verifier = createTokenVerifier({
|
|
42
|
+
accessTokenSecret: process.env.JWT_ACCESS_SECRET,
|
|
43
|
+
audience: "your-client-id",
|
|
44
|
+
issuer: "https://nihplod.cn",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const payload = await verifier.verify(token);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Express / Connect 中间件
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { ssoMiddleware } from "@nihplod/sso-verify";
|
|
54
|
+
|
|
55
|
+
app.use(
|
|
56
|
+
"/api/protected",
|
|
57
|
+
ssoMiddleware({
|
|
58
|
+
introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
59
|
+
clientId: "your-client-id",
|
|
60
|
+
clientSecret: "your-client-secret",
|
|
61
|
+
audience: "your-client-id",
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
验证成功后,`req.user` 会挂载 token payload。
|
|
67
|
+
|
|
68
|
+
## 注意事项
|
|
69
|
+
|
|
70
|
+
- 当前主站默认使用 HS256 对称签名。对于外部子项目,推荐优先使用 **Introspection 模式** 验证 access_token。
|
|
71
|
+
- 若你的子项目是内部服务且已与主站共享 RS256 公钥,可配置 `accessTokenPublicKey` 或 `jwksUri` 做本地 JWT 验证。
|
|
72
|
+
- **不要将 `JWT_ACCESS_SECRET`(HS256 对称密钥)分发给外部子项目。**
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { JWTPayload } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @nihplod/sso-verify
|
|
5
|
+
*
|
|
6
|
+
* NIHPLOD 一网通 SSO Token 验证工具包
|
|
7
|
+
*
|
|
8
|
+
* 供子项目在后端验证主站签发的 Access Token。
|
|
9
|
+
*
|
|
10
|
+
* 由于主站当前使用 HS256 对称签名,JWKS 端点不公开签名密钥,
|
|
11
|
+
* 因此本工具包默认采用 OAuth 2.0 Token Introspection(RFC 7662)
|
|
12
|
+
* 端点进行验证。对于已知共享密钥的内部服务,也可传入
|
|
13
|
+
* accessTokenSecret 做本地 JWT 验证。
|
|
14
|
+
*
|
|
15
|
+
* 安装:npm install @nihplod/sso-verify
|
|
16
|
+
*
|
|
17
|
+
* 使用:
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { createTokenVerifier } from "@nihplod/sso-verify";
|
|
20
|
+
* const verifier = createTokenVerifier({
|
|
21
|
+
* introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
22
|
+
* clientId: "advisor",
|
|
23
|
+
* clientSecret: "YOUR_CLIENT_SECRET",
|
|
24
|
+
* audience: "advisor",
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* const payload = await verifier.verify(token);
|
|
28
|
+
* if (payload) {
|
|
29
|
+
* console.log(payload.sub); // 用户 ID
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface SsoVerifierOptions {
|
|
35
|
+
/** 期望的 audience(对应子项目的 client_id) */
|
|
36
|
+
audience: string;
|
|
37
|
+
/** 期望的 issuer(主站 URL),默认 "https://nihplod.cn" */
|
|
38
|
+
issuer?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Token Introspection 端点 URL。
|
|
41
|
+
* 推荐优先使用,适配当前 HS256 对称签名场景。
|
|
42
|
+
*/
|
|
43
|
+
introspectionEndpoint?: string;
|
|
44
|
+
/** 子项目的 client_id(用于 introspection) */
|
|
45
|
+
clientId?: string;
|
|
46
|
+
/** 子项目的 client_secret(用于 introspection) */
|
|
47
|
+
clientSecret?: string;
|
|
48
|
+
/**
|
|
49
|
+
* 共享的 Access Token Secret(可选)。
|
|
50
|
+
* 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。
|
|
51
|
+
* 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。
|
|
52
|
+
*/
|
|
53
|
+
accessTokenSecret?: string;
|
|
54
|
+
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
|
+
introspectCacheTtl?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
|
+
* 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
|
|
59
|
+
* 避免每次都调用 Introspection 端点。
|
|
60
|
+
*/
|
|
61
|
+
accessTokenPublicKey?: string;
|
|
62
|
+
/**
|
|
63
|
+
* JWKS 端点 URL(可选)。
|
|
64
|
+
* 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。
|
|
65
|
+
* 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥。
|
|
66
|
+
*/
|
|
67
|
+
jwksUri?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Logout Token Secret(可选)。
|
|
70
|
+
* 用于本地验证主站签发的 logout_token(HS256 签名)。
|
|
71
|
+
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
|
+
*/
|
|
73
|
+
logoutTokenSecret?: string;
|
|
74
|
+
}
|
|
75
|
+
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
|
+
sub: string;
|
|
77
|
+
aud: string;
|
|
78
|
+
iss: string;
|
|
79
|
+
client_id?: string;
|
|
80
|
+
scope?: string;
|
|
81
|
+
phone?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
|
|
85
|
+
*
|
|
86
|
+
* 主站签发 logout_token 时包含以下字段:
|
|
87
|
+
* - iss: 主站 issuer
|
|
88
|
+
* - aud: 目标 client_id
|
|
89
|
+
* - sub: 用户 ID
|
|
90
|
+
* - iat: 签发时间
|
|
91
|
+
* - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
|
|
92
|
+
* - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
|
|
93
|
+
*/
|
|
94
|
+
interface LogoutTokenPayload {
|
|
95
|
+
/** JWT type,logout_token 固定值为 "logout_token" */
|
|
96
|
+
type: "logout_token";
|
|
97
|
+
/** 签发者 */
|
|
98
|
+
iss: string;
|
|
99
|
+
/** 目标 audience */
|
|
100
|
+
aud: string;
|
|
101
|
+
/** 用户 ID */
|
|
102
|
+
sub: string;
|
|
103
|
+
/** 签发时间(UNIX timestamp) */
|
|
104
|
+
iat: number;
|
|
105
|
+
/** JWT ID(唯一标识,防重放) */
|
|
106
|
+
jti: string;
|
|
107
|
+
/** Backchannel logout 事件声明 */
|
|
108
|
+
events: {
|
|
109
|
+
"http://schemas.openid.net/event/backchannel-logout": Record<string, never>;
|
|
110
|
+
};
|
|
111
|
+
/** sid (Session ID),可选 */
|
|
112
|
+
sid?: string;
|
|
113
|
+
}
|
|
114
|
+
interface IntrospectResponse {
|
|
115
|
+
active: boolean;
|
|
116
|
+
sub?: string;
|
|
117
|
+
client_id?: string;
|
|
118
|
+
scope?: string;
|
|
119
|
+
exp?: number;
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
123
|
+
/**
|
|
124
|
+
* 验证 Access Token
|
|
125
|
+
*
|
|
126
|
+
* 验证顺序:
|
|
127
|
+
* 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
|
|
128
|
+
* 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
|
|
129
|
+
* 3. 若上述均失败/未配置,调用 Introspection 端点
|
|
130
|
+
*
|
|
131
|
+
* @returns token payload 或者 null(验证失败)
|
|
132
|
+
*/
|
|
133
|
+
verify(token: string): Promise<VerifiedTokenPayload | null>;
|
|
134
|
+
/**
|
|
135
|
+
* 直接调用 Introspection 端点
|
|
136
|
+
*/
|
|
137
|
+
introspect: (token: string) => Promise<IntrospectResponse | null>;
|
|
138
|
+
/**
|
|
139
|
+
* 清除指定 token 的 Introspection 缓存
|
|
140
|
+
*/
|
|
141
|
+
invalidateCache(token: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* 验证 Logout Token(Back-Channel Logout)
|
|
144
|
+
*
|
|
145
|
+
* 用于子项目接收主站 backchannel logout 通知时验证 logout_token。
|
|
146
|
+
* 验证要求(OIDC Back-Channel Logout 1.0):
|
|
147
|
+
* 1. type === "logout_token"
|
|
148
|
+
* 2. iss 匹配配置的 issuer
|
|
149
|
+
* 3. aud 包含当前 client_id
|
|
150
|
+
* 4. events 包含 backchannel-logout 事件
|
|
151
|
+
*
|
|
152
|
+
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
153
|
+
*/
|
|
154
|
+
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Express/Next.js 兼容中间件
|
|
158
|
+
*
|
|
159
|
+
* 使用示例(Express):
|
|
160
|
+
* ```typescript
|
|
161
|
+
* app.use(ssoMiddleware({
|
|
162
|
+
* introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
163
|
+
* clientId: "advisor",
|
|
164
|
+
* clientSecret: "SECRET",
|
|
165
|
+
* audience: "advisor",
|
|
166
|
+
* }));
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* 使用示例(Next.js Route Handler):
|
|
170
|
+
* ```typescript
|
|
171
|
+
* const verifier = createTokenVerifier({ ... });
|
|
172
|
+
* export async function GET(request: NextRequest) {
|
|
173
|
+
* const token = request.headers.get("authorization")?.replace("Bearer ", "");
|
|
174
|
+
* const payload = token ? await verifier.verify(token) : null;
|
|
175
|
+
* if (!payload) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
176
|
+
* // ...
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* 创建 Logout Token 专用验证器
|
|
183
|
+
*
|
|
184
|
+
* 适用于仅需处理 backchannel logout 的子项目。
|
|
185
|
+
* 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。
|
|
186
|
+
*
|
|
187
|
+
* 使用示例:
|
|
188
|
+
* ```typescript
|
|
189
|
+
* import { createLogoutTokenVerifier } from "@nihplod/sso-verify";
|
|
190
|
+
*
|
|
191
|
+
* const logoutVerifier = createLogoutTokenVerifier({
|
|
192
|
+
* audience: "advisor",
|
|
193
|
+
* issuer: "https://nihplod.cn",
|
|
194
|
+
* logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* // 在 backchannel logout 端点中:
|
|
198
|
+
* app.post("/api/auth/backchannel-logout", async (req, res) => {
|
|
199
|
+
* const token = req.body.logout_token;
|
|
200
|
+
* const payload = await logoutVerifier.verify(token);
|
|
201
|
+
* if (payload) {
|
|
202
|
+
* await clearUserSession(payload.sub);
|
|
203
|
+
* res.status(200).end();
|
|
204
|
+
* } else {
|
|
205
|
+
* res.status(400).end();
|
|
206
|
+
* }
|
|
207
|
+
* });
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
211
|
+
/**
|
|
212
|
+
* 验证 logout_token
|
|
213
|
+
*/
|
|
214
|
+
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { JWTPayload } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @nihplod/sso-verify
|
|
5
|
+
*
|
|
6
|
+
* NIHPLOD 一网通 SSO Token 验证工具包
|
|
7
|
+
*
|
|
8
|
+
* 供子项目在后端验证主站签发的 Access Token。
|
|
9
|
+
*
|
|
10
|
+
* 由于主站当前使用 HS256 对称签名,JWKS 端点不公开签名密钥,
|
|
11
|
+
* 因此本工具包默认采用 OAuth 2.0 Token Introspection(RFC 7662)
|
|
12
|
+
* 端点进行验证。对于已知共享密钥的内部服务,也可传入
|
|
13
|
+
* accessTokenSecret 做本地 JWT 验证。
|
|
14
|
+
*
|
|
15
|
+
* 安装:npm install @nihplod/sso-verify
|
|
16
|
+
*
|
|
17
|
+
* 使用:
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { createTokenVerifier } from "@nihplod/sso-verify";
|
|
20
|
+
* const verifier = createTokenVerifier({
|
|
21
|
+
* introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
22
|
+
* clientId: "advisor",
|
|
23
|
+
* clientSecret: "YOUR_CLIENT_SECRET",
|
|
24
|
+
* audience: "advisor",
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* const payload = await verifier.verify(token);
|
|
28
|
+
* if (payload) {
|
|
29
|
+
* console.log(payload.sub); // 用户 ID
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface SsoVerifierOptions {
|
|
35
|
+
/** 期望的 audience(对应子项目的 client_id) */
|
|
36
|
+
audience: string;
|
|
37
|
+
/** 期望的 issuer(主站 URL),默认 "https://nihplod.cn" */
|
|
38
|
+
issuer?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Token Introspection 端点 URL。
|
|
41
|
+
* 推荐优先使用,适配当前 HS256 对称签名场景。
|
|
42
|
+
*/
|
|
43
|
+
introspectionEndpoint?: string;
|
|
44
|
+
/** 子项目的 client_id(用于 introspection) */
|
|
45
|
+
clientId?: string;
|
|
46
|
+
/** 子项目的 client_secret(用于 introspection) */
|
|
47
|
+
clientSecret?: string;
|
|
48
|
+
/**
|
|
49
|
+
* 共享的 Access Token Secret(可选)。
|
|
50
|
+
* 仅在子项目与主站共享 JWT_ACCESS_SECRET 时使用本地验证。
|
|
51
|
+
* 注意:当前 JWKS 端点不公开对称密钥,因此不能通过 JWKS 获取此值。
|
|
52
|
+
*/
|
|
53
|
+
accessTokenSecret?: string;
|
|
54
|
+
/** Introspection 结果缓存 TTL(毫秒),默认 30 秒 */
|
|
55
|
+
introspectCacheTtl?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Access Token RS256 公钥(PEM 格式,可选)。
|
|
58
|
+
* 主站已迁移至 RS256 签名,子项目可传入此公钥进行本地验证,
|
|
59
|
+
* 避免每次都调用 Introspection 端点。
|
|
60
|
+
*/
|
|
61
|
+
accessTokenPublicKey?: string;
|
|
62
|
+
/**
|
|
63
|
+
* JWKS 端点 URL(可选)。
|
|
64
|
+
* 子项目可传入此 URL 以动态获取 RS256 公钥进行本地验证。
|
|
65
|
+
* 当 accessTokenPublicKey 未配置时,将通过此端点获取匹配 kid 的公钥。
|
|
66
|
+
*/
|
|
67
|
+
jwksUri?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Logout Token Secret(可选)。
|
|
70
|
+
* 用于本地验证主站签发的 logout_token(HS256 签名)。
|
|
71
|
+
* 若未提供,将回退使用 accessTokenSecret 进行验证。
|
|
72
|
+
*/
|
|
73
|
+
logoutTokenSecret?: string;
|
|
74
|
+
}
|
|
75
|
+
interface VerifiedTokenPayload extends JWTPayload {
|
|
76
|
+
sub: string;
|
|
77
|
+
aud: string;
|
|
78
|
+
iss: string;
|
|
79
|
+
client_id?: string;
|
|
80
|
+
scope?: string;
|
|
81
|
+
phone?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Logout Token Payload(RFC 7519 + OIDC Back-Channel Logout 1.0)
|
|
85
|
+
*
|
|
86
|
+
* 主站签发 logout_token 时包含以下字段:
|
|
87
|
+
* - iss: 主站 issuer
|
|
88
|
+
* - aud: 目标 client_id
|
|
89
|
+
* - sub: 用户 ID
|
|
90
|
+
* - iat: 签发时间
|
|
91
|
+
* - jti: 唯一 ID(防重放,建议接收方缓存已处理的 jti)
|
|
92
|
+
* - events: { "http://schemas.openid.net/event/backchannel-logout": {} }
|
|
93
|
+
*/
|
|
94
|
+
interface LogoutTokenPayload {
|
|
95
|
+
/** JWT type,logout_token 固定值为 "logout_token" */
|
|
96
|
+
type: "logout_token";
|
|
97
|
+
/** 签发者 */
|
|
98
|
+
iss: string;
|
|
99
|
+
/** 目标 audience */
|
|
100
|
+
aud: string;
|
|
101
|
+
/** 用户 ID */
|
|
102
|
+
sub: string;
|
|
103
|
+
/** 签发时间(UNIX timestamp) */
|
|
104
|
+
iat: number;
|
|
105
|
+
/** JWT ID(唯一标识,防重放) */
|
|
106
|
+
jti: string;
|
|
107
|
+
/** Backchannel logout 事件声明 */
|
|
108
|
+
events: {
|
|
109
|
+
"http://schemas.openid.net/event/backchannel-logout": Record<string, never>;
|
|
110
|
+
};
|
|
111
|
+
/** sid (Session ID),可选 */
|
|
112
|
+
sid?: string;
|
|
113
|
+
}
|
|
114
|
+
interface IntrospectResponse {
|
|
115
|
+
active: boolean;
|
|
116
|
+
sub?: string;
|
|
117
|
+
client_id?: string;
|
|
118
|
+
scope?: string;
|
|
119
|
+
exp?: number;
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
declare function createTokenVerifier(options: SsoVerifierOptions): {
|
|
123
|
+
/**
|
|
124
|
+
* 验证 Access Token
|
|
125
|
+
*
|
|
126
|
+
* 验证顺序:
|
|
127
|
+
* 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
|
|
128
|
+
* 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
|
|
129
|
+
* 3. 若上述均失败/未配置,调用 Introspection 端点
|
|
130
|
+
*
|
|
131
|
+
* @returns token payload 或者 null(验证失败)
|
|
132
|
+
*/
|
|
133
|
+
verify(token: string): Promise<VerifiedTokenPayload | null>;
|
|
134
|
+
/**
|
|
135
|
+
* 直接调用 Introspection 端点
|
|
136
|
+
*/
|
|
137
|
+
introspect: (token: string) => Promise<IntrospectResponse | null>;
|
|
138
|
+
/**
|
|
139
|
+
* 清除指定 token 的 Introspection 缓存
|
|
140
|
+
*/
|
|
141
|
+
invalidateCache(token: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* 验证 Logout Token(Back-Channel Logout)
|
|
144
|
+
*
|
|
145
|
+
* 用于子项目接收主站 backchannel logout 通知时验证 logout_token。
|
|
146
|
+
* 验证要求(OIDC Back-Channel Logout 1.0):
|
|
147
|
+
* 1. type === "logout_token"
|
|
148
|
+
* 2. iss 匹配配置的 issuer
|
|
149
|
+
* 3. aud 包含当前 client_id
|
|
150
|
+
* 4. events 包含 backchannel-logout 事件
|
|
151
|
+
*
|
|
152
|
+
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
153
|
+
*/
|
|
154
|
+
verifyLogoutToken(token: string): Promise<LogoutTokenPayload | null>;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Express/Next.js 兼容中间件
|
|
158
|
+
*
|
|
159
|
+
* 使用示例(Express):
|
|
160
|
+
* ```typescript
|
|
161
|
+
* app.use(ssoMiddleware({
|
|
162
|
+
* introspectionEndpoint: "https://nihplod.cn/api/oauth/introspect",
|
|
163
|
+
* clientId: "advisor",
|
|
164
|
+
* clientSecret: "SECRET",
|
|
165
|
+
* audience: "advisor",
|
|
166
|
+
* }));
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* 使用示例(Next.js Route Handler):
|
|
170
|
+
* ```typescript
|
|
171
|
+
* const verifier = createTokenVerifier({ ... });
|
|
172
|
+
* export async function GET(request: NextRequest) {
|
|
173
|
+
* const token = request.headers.get("authorization")?.replace("Bearer ", "");
|
|
174
|
+
* const payload = token ? await verifier.verify(token) : null;
|
|
175
|
+
* if (!payload) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
176
|
+
* // ...
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
declare function ssoMiddleware(options: SsoVerifierOptions): (req: any, res: any, next: () => void) => Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* 创建 Logout Token 专用验证器
|
|
183
|
+
*
|
|
184
|
+
* 适用于仅需处理 backchannel logout 的子项目。
|
|
185
|
+
* 内部调用 createTokenVerifier 并暴露 verifyLogoutToken 方法。
|
|
186
|
+
*
|
|
187
|
+
* 使用示例:
|
|
188
|
+
* ```typescript
|
|
189
|
+
* import { createLogoutTokenVerifier } from "@nihplod/sso-verify";
|
|
190
|
+
*
|
|
191
|
+
* const logoutVerifier = createLogoutTokenVerifier({
|
|
192
|
+
* audience: "advisor",
|
|
193
|
+
* issuer: "https://nihplod.cn",
|
|
194
|
+
* logoutTokenSecret: process.env.LOGOUT_TOKEN_SECRET,
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* // 在 backchannel logout 端点中:
|
|
198
|
+
* app.post("/api/auth/backchannel-logout", async (req, res) => {
|
|
199
|
+
* const token = req.body.logout_token;
|
|
200
|
+
* const payload = await logoutVerifier.verify(token);
|
|
201
|
+
* if (payload) {
|
|
202
|
+
* await clearUserSession(payload.sub);
|
|
203
|
+
* res.status(200).end();
|
|
204
|
+
* } else {
|
|
205
|
+
* res.status(400).end();
|
|
206
|
+
* }
|
|
207
|
+
* });
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
declare function createLogoutTokenVerifier(options: SsoVerifierOptions): {
|
|
211
|
+
/**
|
|
212
|
+
* 验证 logout_token
|
|
213
|
+
*/
|
|
214
|
+
verify(token: string): Promise<LogoutTokenPayload | null>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export { type LogoutTokenPayload, type SsoVerifierOptions, type VerifiedTokenPayload, createLogoutTokenVerifier, createTokenVerifier, ssoMiddleware };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createLogoutTokenVerifier: () => createLogoutTokenVerifier,
|
|
24
|
+
createTokenVerifier: () => createTokenVerifier,
|
|
25
|
+
ssoMiddleware: () => ssoMiddleware
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_jose = require("jose");
|
|
29
|
+
var import_lru_cache = require("lru-cache");
|
|
30
|
+
function createIntrospectCache(ttlMs) {
|
|
31
|
+
return new import_lru_cache.LRUCache({
|
|
32
|
+
max: 1e4,
|
|
33
|
+
ttl: ttlMs
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
var processedLogoutJtis = new import_lru_cache.LRUCache({
|
|
37
|
+
max: 1e4,
|
|
38
|
+
ttl: 10 * 60 * 1e3
|
|
39
|
+
});
|
|
40
|
+
function createTokenVerifier(options) {
|
|
41
|
+
const {
|
|
42
|
+
audience,
|
|
43
|
+
issuer = "https://nihplod.cn",
|
|
44
|
+
introspectionEndpoint,
|
|
45
|
+
clientId,
|
|
46
|
+
clientSecret,
|
|
47
|
+
accessTokenSecret,
|
|
48
|
+
accessTokenPublicKey,
|
|
49
|
+
jwksUri,
|
|
50
|
+
logoutTokenSecret,
|
|
51
|
+
introspectCacheTtl = 30 * 1e3
|
|
52
|
+
} = options;
|
|
53
|
+
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
54
|
+
let _jwksKeySet = null;
|
|
55
|
+
function getJwksKeySet() {
|
|
56
|
+
if (!jwksUri) return null;
|
|
57
|
+
if (!_jwksKeySet) {
|
|
58
|
+
_jwksKeySet = (0, import_jose.createRemoteJWKSet)(new URL(jwksUri), {
|
|
59
|
+
cacheMaxAge: 60 * 60 * 1e3
|
|
60
|
+
// 1 小时缓存
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return _jwksKeySet;
|
|
64
|
+
}
|
|
65
|
+
let _rs256PublicKey = null;
|
|
66
|
+
let _rs256KeyInitialized = false;
|
|
67
|
+
async function getRS256PublicKey() {
|
|
68
|
+
if (!_rs256KeyInitialized) {
|
|
69
|
+
_rs256KeyInitialized = true;
|
|
70
|
+
if (!accessTokenPublicKey) {
|
|
71
|
+
_rs256PublicKey = null;
|
|
72
|
+
} else {
|
|
73
|
+
try {
|
|
74
|
+
_rs256PublicKey = await (0, import_jose.importSPKI)(accessTokenPublicKey, "RS256");
|
|
75
|
+
} catch {
|
|
76
|
+
_rs256PublicKey = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return _rs256PublicKey;
|
|
81
|
+
}
|
|
82
|
+
async function introspect(token) {
|
|
83
|
+
if (!introspectionEndpoint || !clientId || !clientSecret) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const cached = introspectCache.get(token);
|
|
87
|
+
if (cached) {
|
|
88
|
+
return { active: cached.active, ...cached.payload || {} };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(introspectionEndpoint, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
|
+
body: new URLSearchParams({
|
|
95
|
+
token,
|
|
96
|
+
client_id: clientId,
|
|
97
|
+
client_secret: clientSecret
|
|
98
|
+
})
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) return null;
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
let payload = null;
|
|
103
|
+
if (data.active && data.sub) {
|
|
104
|
+
payload = {
|
|
105
|
+
sub: data.sub,
|
|
106
|
+
aud: audience,
|
|
107
|
+
iss: issuer,
|
|
108
|
+
client_id: data.client_id,
|
|
109
|
+
scope: data.scope,
|
|
110
|
+
exp: data.exp
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
introspectCache.set(token, { active: data.active, payload });
|
|
114
|
+
return data;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function verifyLocally(token) {
|
|
120
|
+
if (!accessTokenSecret) return null;
|
|
121
|
+
try {
|
|
122
|
+
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
123
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, secret, {
|
|
124
|
+
issuer,
|
|
125
|
+
audience
|
|
126
|
+
});
|
|
127
|
+
if (payload.type !== "access_token") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return payload;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function verifyWithRS256(token) {
|
|
136
|
+
const hasPublicKey = Boolean(accessTokenPublicKey);
|
|
137
|
+
const hasJwks = Boolean(jwksUri);
|
|
138
|
+
if (!hasPublicKey && !hasJwks) return null;
|
|
139
|
+
try {
|
|
140
|
+
const directKey = await getRS256PublicKey();
|
|
141
|
+
if (directKey) {
|
|
142
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, directKey, {
|
|
143
|
+
issuer,
|
|
144
|
+
audience,
|
|
145
|
+
algorithms: ["RS256"]
|
|
146
|
+
});
|
|
147
|
+
if (payload.type !== "access_token") return null;
|
|
148
|
+
return payload;
|
|
149
|
+
}
|
|
150
|
+
const jwks = getJwksKeySet();
|
|
151
|
+
if (jwks) {
|
|
152
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, {
|
|
153
|
+
issuer,
|
|
154
|
+
audience,
|
|
155
|
+
algorithms: ["RS256"]
|
|
156
|
+
});
|
|
157
|
+
if (payload.type !== "access_token") return null;
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
/**
|
|
167
|
+
* 验证 Access Token
|
|
168
|
+
*
|
|
169
|
+
* 验证顺序:
|
|
170
|
+
* 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
|
|
171
|
+
* 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
|
|
172
|
+
* 3. 若上述均失败/未配置,调用 Introspection 端点
|
|
173
|
+
*
|
|
174
|
+
* @returns token payload 或者 null(验证失败)
|
|
175
|
+
*/
|
|
176
|
+
async verify(token) {
|
|
177
|
+
if (accessTokenSecret) {
|
|
178
|
+
const local = await verifyLocally(token);
|
|
179
|
+
if (local) return local;
|
|
180
|
+
}
|
|
181
|
+
const rs256Result = await verifyWithRS256(token);
|
|
182
|
+
if (rs256Result) return rs256Result;
|
|
183
|
+
const result = await introspect(token);
|
|
184
|
+
if (!result?.active) return null;
|
|
185
|
+
return {
|
|
186
|
+
sub: result.sub || "",
|
|
187
|
+
aud: audience,
|
|
188
|
+
iss: issuer,
|
|
189
|
+
client_id: result.client_id,
|
|
190
|
+
scope: result.scope,
|
|
191
|
+
exp: result.exp
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
/**
|
|
195
|
+
* 直接调用 Introspection 端点
|
|
196
|
+
*/
|
|
197
|
+
introspect,
|
|
198
|
+
/**
|
|
199
|
+
* 清除指定 token 的 Introspection 缓存
|
|
200
|
+
*/
|
|
201
|
+
invalidateCache(token) {
|
|
202
|
+
introspectCache.delete(token);
|
|
203
|
+
},
|
|
204
|
+
/**
|
|
205
|
+
* 验证 Logout Token(Back-Channel Logout)
|
|
206
|
+
*
|
|
207
|
+
* 用于子项目接收主站 backchannel logout 通知时验证 logout_token。
|
|
208
|
+
* 验证要求(OIDC Back-Channel Logout 1.0):
|
|
209
|
+
* 1. type === "logout_token"
|
|
210
|
+
* 2. iss 匹配配置的 issuer
|
|
211
|
+
* 3. aud 包含当前 client_id
|
|
212
|
+
* 4. events 包含 backchannel-logout 事件
|
|
213
|
+
*
|
|
214
|
+
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
215
|
+
*/
|
|
216
|
+
async verifyLogoutToken(token) {
|
|
217
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
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
|
+
});
|
|
226
|
+
if (payload.type !== "logout_token") {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const events = payload.events;
|
|
230
|
+
if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const jti = payload.jti;
|
|
234
|
+
if (!jti || typeof jti !== "string") {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (processedLogoutJtis.has(jti)) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
processedLogoutJtis.set(jti, Date.now());
|
|
241
|
+
return payload;
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function ssoMiddleware(options) {
|
|
249
|
+
const verifier = createTokenVerifier(options);
|
|
250
|
+
return async (req, res, next) => {
|
|
251
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization || "";
|
|
252
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
253
|
+
if (!token) {
|
|
254
|
+
if (res.status) {
|
|
255
|
+
res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const payload = await verifier.verify(token);
|
|
260
|
+
if (!payload) {
|
|
261
|
+
if (res.status) {
|
|
262
|
+
res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
req.user = payload;
|
|
267
|
+
next();
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function createLogoutTokenVerifier(options) {
|
|
271
|
+
const verifier = createTokenVerifier(options);
|
|
272
|
+
return {
|
|
273
|
+
/**
|
|
274
|
+
* 验证 logout_token
|
|
275
|
+
*/
|
|
276
|
+
async verify(token) {
|
|
277
|
+
return verifier.verifyLogoutToken(token);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
282
|
+
0 && (module.exports = {
|
|
283
|
+
createLogoutTokenVerifier,
|
|
284
|
+
createTokenVerifier,
|
|
285
|
+
ssoMiddleware
|
|
286
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { jwtVerify, importSPKI, createRemoteJWKSet } from "jose";
|
|
3
|
+
import { LRUCache } from "lru-cache";
|
|
4
|
+
function createIntrospectCache(ttlMs) {
|
|
5
|
+
return new LRUCache({
|
|
6
|
+
max: 1e4,
|
|
7
|
+
ttl: ttlMs
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
var processedLogoutJtis = new LRUCache({
|
|
11
|
+
max: 1e4,
|
|
12
|
+
ttl: 10 * 60 * 1e3
|
|
13
|
+
});
|
|
14
|
+
function createTokenVerifier(options) {
|
|
15
|
+
const {
|
|
16
|
+
audience,
|
|
17
|
+
issuer = "https://nihplod.cn",
|
|
18
|
+
introspectionEndpoint,
|
|
19
|
+
clientId,
|
|
20
|
+
clientSecret,
|
|
21
|
+
accessTokenSecret,
|
|
22
|
+
accessTokenPublicKey,
|
|
23
|
+
jwksUri,
|
|
24
|
+
logoutTokenSecret,
|
|
25
|
+
introspectCacheTtl = 30 * 1e3
|
|
26
|
+
} = options;
|
|
27
|
+
const introspectCache = createIntrospectCache(introspectCacheTtl);
|
|
28
|
+
let _jwksKeySet = null;
|
|
29
|
+
function getJwksKeySet() {
|
|
30
|
+
if (!jwksUri) return null;
|
|
31
|
+
if (!_jwksKeySet) {
|
|
32
|
+
_jwksKeySet = createRemoteJWKSet(new URL(jwksUri), {
|
|
33
|
+
cacheMaxAge: 60 * 60 * 1e3
|
|
34
|
+
// 1 小时缓存
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return _jwksKeySet;
|
|
38
|
+
}
|
|
39
|
+
let _rs256PublicKey = null;
|
|
40
|
+
let _rs256KeyInitialized = false;
|
|
41
|
+
async function getRS256PublicKey() {
|
|
42
|
+
if (!_rs256KeyInitialized) {
|
|
43
|
+
_rs256KeyInitialized = true;
|
|
44
|
+
if (!accessTokenPublicKey) {
|
|
45
|
+
_rs256PublicKey = null;
|
|
46
|
+
} else {
|
|
47
|
+
try {
|
|
48
|
+
_rs256PublicKey = await importSPKI(accessTokenPublicKey, "RS256");
|
|
49
|
+
} catch {
|
|
50
|
+
_rs256PublicKey = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return _rs256PublicKey;
|
|
55
|
+
}
|
|
56
|
+
async function introspect(token) {
|
|
57
|
+
if (!introspectionEndpoint || !clientId || !clientSecret) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const cached = introspectCache.get(token);
|
|
61
|
+
if (cached) {
|
|
62
|
+
return { active: cached.active, ...cached.payload || {} };
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(introspectionEndpoint, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
68
|
+
body: new URLSearchParams({
|
|
69
|
+
token,
|
|
70
|
+
client_id: clientId,
|
|
71
|
+
client_secret: clientSecret
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) return null;
|
|
75
|
+
const data = await response.json();
|
|
76
|
+
let payload = null;
|
|
77
|
+
if (data.active && data.sub) {
|
|
78
|
+
payload = {
|
|
79
|
+
sub: data.sub,
|
|
80
|
+
aud: audience,
|
|
81
|
+
iss: issuer,
|
|
82
|
+
client_id: data.client_id,
|
|
83
|
+
scope: data.scope,
|
|
84
|
+
exp: data.exp
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
introspectCache.set(token, { active: data.active, payload });
|
|
88
|
+
return data;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function verifyLocally(token) {
|
|
94
|
+
if (!accessTokenSecret) return null;
|
|
95
|
+
try {
|
|
96
|
+
const secret = new TextEncoder().encode(accessTokenSecret);
|
|
97
|
+
const { payload } = await jwtVerify(token, secret, {
|
|
98
|
+
issuer,
|
|
99
|
+
audience
|
|
100
|
+
});
|
|
101
|
+
if (payload.type !== "access_token") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return payload;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function verifyWithRS256(token) {
|
|
110
|
+
const hasPublicKey = Boolean(accessTokenPublicKey);
|
|
111
|
+
const hasJwks = Boolean(jwksUri);
|
|
112
|
+
if (!hasPublicKey && !hasJwks) return null;
|
|
113
|
+
try {
|
|
114
|
+
const directKey = await getRS256PublicKey();
|
|
115
|
+
if (directKey) {
|
|
116
|
+
const { payload } = await jwtVerify(token, directKey, {
|
|
117
|
+
issuer,
|
|
118
|
+
audience,
|
|
119
|
+
algorithms: ["RS256"]
|
|
120
|
+
});
|
|
121
|
+
if (payload.type !== "access_token") return null;
|
|
122
|
+
return payload;
|
|
123
|
+
}
|
|
124
|
+
const jwks = getJwksKeySet();
|
|
125
|
+
if (jwks) {
|
|
126
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
127
|
+
issuer,
|
|
128
|
+
audience,
|
|
129
|
+
algorithms: ["RS256"]
|
|
130
|
+
});
|
|
131
|
+
if (payload.type !== "access_token") return null;
|
|
132
|
+
return payload;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
/**
|
|
141
|
+
* 验证 Access Token
|
|
142
|
+
*
|
|
143
|
+
* 验证顺序:
|
|
144
|
+
* 1. 若配置了 accessTokenSecret,先尝试 HS256 本地验证
|
|
145
|
+
* 2. 若配置了 accessTokenPublicKey 或 jwksUri,尝试 RS256 本地验证
|
|
146
|
+
* 3. 若上述均失败/未配置,调用 Introspection 端点
|
|
147
|
+
*
|
|
148
|
+
* @returns token payload 或者 null(验证失败)
|
|
149
|
+
*/
|
|
150
|
+
async verify(token) {
|
|
151
|
+
if (accessTokenSecret) {
|
|
152
|
+
const local = await verifyLocally(token);
|
|
153
|
+
if (local) return local;
|
|
154
|
+
}
|
|
155
|
+
const rs256Result = await verifyWithRS256(token);
|
|
156
|
+
if (rs256Result) return rs256Result;
|
|
157
|
+
const result = await introspect(token);
|
|
158
|
+
if (!result?.active) return null;
|
|
159
|
+
return {
|
|
160
|
+
sub: result.sub || "",
|
|
161
|
+
aud: audience,
|
|
162
|
+
iss: issuer,
|
|
163
|
+
client_id: result.client_id,
|
|
164
|
+
scope: result.scope,
|
|
165
|
+
exp: result.exp
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
/**
|
|
169
|
+
* 直接调用 Introspection 端点
|
|
170
|
+
*/
|
|
171
|
+
introspect,
|
|
172
|
+
/**
|
|
173
|
+
* 清除指定 token 的 Introspection 缓存
|
|
174
|
+
*/
|
|
175
|
+
invalidateCache(token) {
|
|
176
|
+
introspectCache.delete(token);
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* 验证 Logout Token(Back-Channel Logout)
|
|
180
|
+
*
|
|
181
|
+
* 用于子项目接收主站 backchannel logout 通知时验证 logout_token。
|
|
182
|
+
* 验证要求(OIDC Back-Channel Logout 1.0):
|
|
183
|
+
* 1. type === "logout_token"
|
|
184
|
+
* 2. iss 匹配配置的 issuer
|
|
185
|
+
* 3. aud 包含当前 client_id
|
|
186
|
+
* 4. events 包含 backchannel-logout 事件
|
|
187
|
+
*
|
|
188
|
+
* @returns LogoutTokenPayload 或 null(验证失败)
|
|
189
|
+
*/
|
|
190
|
+
async verifyLogoutToken(token) {
|
|
191
|
+
const secret = logoutTokenSecret || accessTokenSecret;
|
|
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
|
+
});
|
|
200
|
+
if (payload.type !== "logout_token") {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const events = payload.events;
|
|
204
|
+
if (!events || !events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const jti = payload.jti;
|
|
208
|
+
if (!jti || typeof jti !== "string") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (processedLogoutJtis.has(jti)) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
processedLogoutJtis.set(jti, Date.now());
|
|
215
|
+
return payload;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function ssoMiddleware(options) {
|
|
223
|
+
const verifier = createTokenVerifier(options);
|
|
224
|
+
return async (req, res, next) => {
|
|
225
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization || "";
|
|
226
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
227
|
+
if (!token) {
|
|
228
|
+
if (res.status) {
|
|
229
|
+
res.status(401).json({ error: "Unauthorized", message: "\u8BF7\u63D0\u4F9B Bearer token" });
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const payload = await verifier.verify(token);
|
|
234
|
+
if (!payload) {
|
|
235
|
+
if (res.status) {
|
|
236
|
+
res.status(401).json({ error: "Unauthorized", message: "Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" });
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
req.user = payload;
|
|
241
|
+
next();
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function createLogoutTokenVerifier(options) {
|
|
245
|
+
const verifier = createTokenVerifier(options);
|
|
246
|
+
return {
|
|
247
|
+
/**
|
|
248
|
+
* 验证 logout_token
|
|
249
|
+
*/
|
|
250
|
+
async verify(token) {
|
|
251
|
+
return verifier.verifyLogoutToken(token);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
export {
|
|
256
|
+
createLogoutTokenVerifier,
|
|
257
|
+
createTokenVerifier,
|
|
258
|
+
ssoMiddleware
|
|
259
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nihplod/sso-verify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NIHPLOD 一网通 SSO Token 验证工具包 — 供子项目在后端验证主站签发的 Access Token",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
21
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"sso",
|
|
28
|
+
"oauth2",
|
|
29
|
+
"jwt",
|
|
30
|
+
"jwks",
|
|
31
|
+
"token-verification",
|
|
32
|
+
"nihplod"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/nihplod/nihplod.cn",
|
|
38
|
+
"directory": "packages/sso-verify"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"jose": "^5.0.0",
|
|
43
|
+
"lru-cache": "^11.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"tsup": "^8.0.0",
|
|
47
|
+
"typescript": "^5.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|