@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 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 对称密钥)分发给外部子项目。**
@@ -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 };
@@ -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
+ }