@yixinshuke/node-auth 0.0.0-stage → 0.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/package.json CHANGED
@@ -1,6 +1,21 @@
1
- {
2
- "name": "@yixinshuke/node-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@yixinshuke/node-auth",
3
+ "version": "0.1.0",
4
+ "description": "登录态 JWT 签发/验签 + 开放通道 HMAC 验签 + Fastify 鉴权中间件(双轨 JWT 设计)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "scripts": {
15
+ "verify": "node --import tsx scripts/verify-sign.ts",
16
+ "test": "node --import tsx --test scripts/verify-open-command.ts",
17
+ "pub": "npm publish --access public",
18
+ "pub:dry": "npm publish --access public --dry-run"
19
+ },
20
+ "license": "MIT"
21
+ }
@@ -0,0 +1,280 @@
1
+ import { bearerOf, parseCookies, ACCESS_TOKEN_COOKIE, setAuthCookie, clearAuthCookie, setRefreshCookie, clearRefreshCookie, verifyTokenDom, setTokenCookieNames } from "./jwt.js";
2
+ import { isPublicPrefix } from "./config.js";
3
+
4
+ /**
5
+ * 鉴权中间件(Fastify 应用级 hook)—— **强制鉴权** + 把「可信身份」原样注入请求体。
6
+ *
7
+ * 通用化设计:本包**不认识**任何宿主的业务表列名。要强写哪些字段、叫什么名字,
8
+ * 完全由调用方在 `AuthHookOptions.forceWriteFields` 数组里声明;每个已鉴权请求,
9
+ * 本包把 **token payload 中同名 key 原样拷进请求体、覆盖客户端传值**(「服务端只信令牌、防越权冒充」的落点)。
10
+ *
11
+ * 例:`forceWriteFields = ["I_用户Id","I_代理Id","I_商户Id"]` →
12
+ * token 须携带 `{"I_用户Id":1,"I_代理Id":2,"I_商户Id":3,...}`,本包原样写入 body 这三个字段。
13
+ * 字段数量/名称/语义全部由调用方决定,本包零硬编码列名、可通用。
14
+ *
15
+ * 规则(**fail-closed**):
16
+ * 1. 免鉴权路径(`isPublicPath`)→ 放行;
17
+ * 2. 其余 `/api/*` 必须带**有效** access 令牌:缺令牌 / 无效 / 过期 / 非 access → 401;
18
+ * 3. 校验链抛错 → **503 拒绝**,绝不放行(无法确认身份时放行 = 安全漏洞)。
19
+ *
20
+ * 令牌来源:优先 `Authorization: Bearer <token>`;兼容请求体 `token` / `accessToken`;最后 HttpOnly Cookie。
21
+ */
22
+
23
+
24
+
25
+ /**
26
+ * 服务调用身份(`/open`、`/callback` 这类「appId + 签名」通道专用)。
27
+ *
28
+ * 与人用登录态(JWT 强写)的区别:没有账号、没有租户、没有操作员。
29
+ * 租户由调用方在提交数据里显式指定并被签名保护,服务端不再强写。
30
+ */
31
+ export interface ServiceIdentity {
32
+ /** 调用方应用标识(一个 appId 对应一把独立 secret,可单独吊销/轮换) */
33
+ appId: string;
34
+ }
35
+
36
+ /**
37
+ * 路径是否命中某条服务通道前缀(精确匹配或以 `前缀/` 开头)。
38
+ * 导出是为了让宿主/测试能复用同一套匹配语义,避免各自写 startsWith 走偏。
39
+ */
40
+ export function isServicePath(path: string, prefixes: string[]): boolean {
41
+ const p = String(path ?? "");
42
+ return (prefixes ?? []).some((x) => {
43
+ const s = String(x ?? "").trim();
44
+ if (!s) return false;
45
+ if (p === s || p.startsWith(s + "/")) return true;
46
+ // 面板/契约的紧凑形式也必须纳入同一保护:<prefix>.json / <prefix>.skill
47
+ // 否则它们会掉进 isPublicPath 的「不是 /api 开头就算公开」分支,把接口清单直接裸奔出去。
48
+ return p === `${s}.json` || p === `${s}.skill`;
49
+ });
50
+ }
51
+
52
+ export interface AuthHookOptions {
53
+ /**
54
+ * **强写字段名数组(通用化核心)**:声明本服务要把 token 里哪些字段「原样拷进请求体、覆盖客户端传值」。
55
+ * 字段数量与名称完全由调用方决定,本包不硬编码任何业务列名。
56
+ *
57
+ * 每个已鉴权请求,本包读取 token payload 中**同名 key**,写入 `body[字段名]`(客户端伪造无效)。
58
+ * 例:`["I_用户Id","I_代理Id","I_商户Id"]` → token 须携带这三个 key,原样写入 body。
59
+ * 服务通道(`/open`、`/callback`)下这些字段统一置 0(中性),并标 `role="service"`。
60
+ */
61
+ forceWriteFields: string[];
62
+ /**
63
+ * 服务调用通道的 URL 前缀(如 `["/open", "/callback"]`)。
64
+ * 命中即改走 `resolveService` 验签:**不要求 JWT**,且**不注入用户身份**(身份由调用方在提交数据指定并签名保护)。
65
+ * 不配置则该能力完全关闭,行为与旧版逐字节一致。
66
+ */
67
+ servicePrefixes?: string[];
68
+ /**
69
+ * 服务通道验签器:成功返回服务身份,失败返回 null。
70
+ * 宿主通常用它自己拼装 request-sign 的 `verifySignedRequest`。
71
+ */
72
+ resolveService?: (req: any) => Promise<ServiceIdentity | null>;
73
+ /** 大屏观测端点 /_sys 是否需授权(默认 false 公开) */
74
+ obsRequireAuth?: boolean;
75
+ /**
76
+ * 是否启用「令牌双轨」Cookie 同步(默认 true):认证成功响应(cmd 以 `auth/` 开头)在出网前
77
+ * 种 HttpOnly Cookie(`accessToken`),使地址栏 GET(调试面板)也能自动携带;登出(`ok:true`)清 Cookie。
78
+ * 逻辑与身份注入同属认证能力,一并在此注册,宿主只需调 `registerAuthHook` 一行。
79
+ */
80
+ cookieSync?: boolean;
81
+ /**
82
+ * 调试面板 GET 公开开关(默认 false):
83
+ * - true(开发/测试环境):所有 `/api/*` 的 GET(模块面板页 / 文档 .json / .skill / 批量脚本)一律公开,便于地址栏直接调试;
84
+ * - false(生产环境):所有 `/api/*` GET 强制鉴权(杜绝接口结构裸奔;已登录面板经 Bearer 头拉取,不影响使用)。
85
+ * POST 命令派发始终强制鉴权,与此开关无关。由宿主按 `config.env !== "prod"` 注入。
86
+ */
87
+ publicApiGet?: boolean;
88
+ /**
89
+ * refresh / device 的 Cookie 名覆盖(默认 `refreshToken` / `deviceToken`):
90
+ * 用于同源多服务(如 1dl→`dl*`、2sh→`sh*`)区分 Cookie 名,避免跨服务串号。未配置则保持默认,对其它服务无影响。
91
+ */
92
+ refreshCookieName?: string;
93
+ deviceCookieName?: string;
94
+ }
95
+
96
+ /**
97
+ * 系统观测端点分两类(双出口),与业务模块(/api/<module>)完全分离:
98
+ * - /api/_sys/* —— node-apirouter 内部出口,供调试面板自给(**始终公开**)。
99
+ * - /_sys/* —— 宿主对外出口,供大屏聚合(默认公开;obsRequireAuth=true 时走令牌鉴权)。
100
+ * - /api/<module> 业务接口:生产环境全部强制鉴权(含 GET);**开发/测试环境**所有 `/api/*` 的 GET
101
+ * (面板页/文档/契约/批量脚本)一律公开(便于地址栏调试),POST 派发始终强制鉴权。由 `allowPublicApiGet` 控制。
102
+ */
103
+ /** 调试面板 GET 公开开关:由 registerAuthHook 按环境注入(dev/test=true,prod=false) */
104
+ let allowPublicApiGet = false;
105
+
106
+ export function isPublicPath(method: string, url: string, cmd: string, obsRequireAuth = false): boolean {
107
+ const path = String(url ?? "").split("?")[0];
108
+ const m = String(method ?? "GET").toUpperCase();
109
+
110
+ // 大屏观测对外出口:默认公开;requireAuth 时落到下方令牌鉴权(不在此放行)
111
+ if (path.startsWith("/_sys")) return !obsRequireAuth;
112
+ // 非 /api 且非 /_sys:静态大屏、`/health` 等
113
+ if (!path.startsWith("/api")) return true;
114
+ // node-apirouter 内部观测出口:调试面板用,始终公开
115
+ if (path.startsWith("/api/_sys")) return true;
116
+ // 只匹配认证模块本身、子路径及文档;authorization 等同名前缀的业务模块必须正常鉴权并注入身份。
117
+ if (/^\/api\/auth(?:\/|$|\.(?:json|skill(?:\.md)?|batch\.json)$)/.test(path)) return true;
118
+ // 调试面板文档/契约(.json/.skill)与地址栏 GET /api/*:仅开发/测试环境公开(allowPublicApiGet),
119
+ // 生产环境不放行 —— 所有 /api/* 的 GET 强制鉴权(杜绝接口结构裸奔;已登录面板经 Bearer 头拉取,不影响使用)。
120
+ if (allowPublicApiGet && /^\/api\/[^/]+\.(json|skill)$/.test(path)) return true; // 模块文档/契约
121
+ if (allowPublicApiGet && m === "GET" && path.startsWith("/api/")) return true; // 面板页/批量脚本等
122
+ if (path === "/api" || path === "/api/") {
123
+ // GET /api = 调试面板页面;POST /api = 面板派发(全名 cmd)
124
+ return m !== "POST" || String(cmd ?? "").startsWith("auth/");
125
+ }
126
+ // 宿主注入的免鉴权白名单(增量放行,仅用于真正公开的端点)
127
+ if (isPublicPrefix(path)) return true;
128
+ return false;
129
+ }
130
+
131
+ export function registerAuthHook(app: any, opts: AuthHookOptions): void {
132
+ const obsRequireAuth = opts.obsRequireAuth ?? false;
133
+ // 调试面板 GET 公开开关:dev/test 放开 GET 便于地址栏调试;prod 强制 GET 鉴权
134
+ allowPublicApiGet = opts.publicApiGet === true;
135
+ // refresh/device Cookie 名按服务覆盖(如 1dl→dl*、2sh→sh*),避免跨服务串号
136
+ setTokenCookieNames(opts.refreshCookieName, opts.deviceCookieName);
137
+ // 服务调用通道前缀:不传即退化成纯 JWT 模式,对既有服务零影响
138
+ const svcPrefixes = (opts.servicePrefixes ?? []).map((p) => String(p).trim()).filter(Boolean);
139
+ // 强写字段名(通用化核心):数量/名称由调用方声明,本包零硬编码业务列名
140
+ const forceWriteFields = (opts.forceWriteFields ?? []).map((f) => String(f).trim()).filter(Boolean);
141
+ app.addHook("preValidation", async (request: any, reply: any) => {
142
+ // CORS 预检(OPTIONS)不参与鉴权:按规范浏览器发预检时**不携带任何凭据**
143
+ // (既无 Authorization 头也无 Cookie),若在此要求令牌,跨源业务请求会卡在预检 401。
144
+ // 预检能否放行交由 CORS 来源白名单决定(未命中则不回 CORS 头,浏览器自行拦截)。
145
+ if (String(request.method ?? "").toUpperCase() === "OPTIONS") return;
146
+ const req = request as any;
147
+ const body: any = req.body;
148
+ const cmd = body && typeof body === "object" ? String(body.cmd ?? "") : "";
149
+
150
+ // 统一解析令牌:优先 Bearer 头 → 请求体 token/accessToken → HttpOnly Cookie(地址栏 GET 自动携带);
151
+ // Bearer(头或体)优先于 Cookie,冲突以 Bearer 为准。
152
+ const header = (request.headers as any)?.authorization;
153
+ let token = header
154
+ ? bearerOf(header)
155
+ : body && typeof body === "object"
156
+ ? bearerOf(body.accessToken)
157
+ : "";
158
+ if (!token) {
159
+ const cookies = parseCookies((request.headers as any)?.cookie);
160
+ token = cookies[ACCESS_TOKEN_COOKIE] ?? "";
161
+ }
162
+ // 把解析出的令牌回写 body.accessToken(让头/体/Cookie 三种调用方式被业务 handler 一致识别;auth 模块各 cmd
163
+ // 只能从请求体读 token 的坑——调试面板把 token 放在 Authorization 头,中间件若不回写,handler 会误判「未登录」)。
164
+ // 此回写只动 accessToken 字段,绝不动 I_商户Id(auth 模块的 I_商户Id 语义是「目标商户」,由业务自行解析)。
165
+ if (body && typeof body === "object" && token) body.accessToken = token;
166
+
167
+ // 统一拒绝出口(提前到此处定义:服务通道分支在 isPublicPath 之前,也要用它)
168
+ // 统一拒绝出口:与 apirouter 信封保持一致 —— **HTTP 恒 200**,业务结论由 body.result 表达。
169
+ // 401 → 未登录(前端按 result 跳登录/静默续期);5xx → 异常(鉴权基础设施故障,需告警);其余 → 错误。
170
+ // ⚠️ 不再用 HTTP 401/503 表达业务结论:HTTP 码只表示「请求有没有拿到信封」。
171
+ const deny = (code: number, message: string) => {
172
+ const result = code >= 500 ? "异常" : code === 401 ? "未登录" : "错误";
173
+ return reply
174
+ .code(200)
175
+ .send({ cmd, result, error: message, time: 0, requestId: String(req.id ?? "") }) as any;
176
+ };
177
+
178
+ // —— 服务调用通道(appId + 签名):必须**先于** isPublicPath 判断 ——
179
+ // 因为 isPublicPath 里有一条「不是 /api 开头就算公开」的规则(原本是给静态资源/健康检查用的),
180
+ // /open 与 /callback 若走那条规则会被直接放行变成裸奔接口。
181
+ const reqPath = String(req.url ?? "").split("?")[0];
182
+ if (isServicePath(reqPath, svcPrefixes)) {
183
+ // 只读文档直接放行:面板页 / `.json` 契约 / `.skill` 说明书 —— 等价于开放平台的在线文档,
184
+ // 浏览器必须能直接打开,调用方也靠它查参数。**只有产生副作用的写请求才验签。**
185
+ const verb = String(req.method ?? "GET").toUpperCase();
186
+ if (verb === "GET" || verb === "HEAD" || verb === "OPTIONS") return;
187
+ try {
188
+ const svc = opts.resolveService ? await opts.resolveService(req) : null;
189
+ if (!svc) return deny(401, "服务调用鉴权失败:AppId 或签名无效");
190
+ // 挂到 request 上,供 apirouter 装载器透传给业务 handler 的 ctx.service(绕过 zod strip)
191
+ (req as any).__service = { appId: String(svc.appId ?? "") };
192
+ if (body && typeof body === "object") {
193
+ // 服务通道**不注入用户身份**:所有强写字段置 0(中性,表示「无登录用户」),并标 role=service
194
+ // 以示机器签名调用——业务 handler 据此区分「人登录态」与「机器调用」。字段名跟随 forceWriteFields,本包零硬编码。
195
+ for (const f of forceWriteFields) body[f] = 0;
196
+ body.role = "service";
197
+ // 调用者身份标记(service 通道):dom=service,供 handler 统一判定 Machine 调用
198
+ body.__auth = { dom: "service" };
199
+ }
200
+ return;
201
+ } catch (e) {
202
+ // fail-closed:验签链路异常(密钥库不可用等)无法确认身份 → 拒绝,绝不放行
203
+ console.error("[auth] 服务调用鉴权异常,已拒绝请求:", (e as Error).message);
204
+ return deny(503, "鉴权服务暂不可用,请稍后重试");
205
+ }
206
+ }
207
+
208
+ if (isPublicPath(String(req.method ?? ""), String(req.url ?? ""), cmd, obsRequireAuth)) return;
209
+
210
+ try {
211
+ if (!token) return deny(401, "未登录:请在 Authorization 头(Bearer)或请求体传 token");
212
+
213
+ const v = verifyTokenDom(token);
214
+ // 仅接受正式 access 令牌;pre 预登录票据(用于选商户)不可当业务令牌用
215
+ if (!v || v.payload.typ !== "access") return deny(401, "登录已过期或令牌无效,请重新登录");
216
+ const payload = v.payload;
217
+ const dom = v.dom;
218
+
219
+ if (body && typeof body === "object") {
220
+ // 通用强写:token payload 中同名 key 原样覆盖客户端传值,防冒充(本包不识任何业务列名)
221
+ for (const f of forceWriteFields) body[f] = payload[f];
222
+ // 调用者身份全集(dom + 全部载荷声明):业务 handler 据此区分 partner/merchant/service,
223
+ // 并取级别链(I_伙伴级别1~6编号)等做数据范围过滤。服务端强写,客户端伪造的 __auth 会被覆盖。
224
+ body.__auth = { ...payload, dom };
225
+ }
226
+ } catch (e) {
227
+ // fail-closed:鉴权校验链异常(如 DB 不可用)无法确认身份 → 拒绝,绝不放行
228
+ console.error("[auth] 身份校验异常,已拒绝请求:", (e as Error).message);
229
+ return deny(503, "鉴权服务暂不可用,请稍后重试");
230
+ }
231
+ });
232
+ // 令牌双轨 Cookie 同步(默认开):认证响应种/清 HttpOnly Cookie,地址栏 GET 自动携带。
233
+ // 与上面的身份注入同属认证能力,一并在此注册,宿主只需调 registerAuthHook 一行。
234
+ if (opts.cookieSync !== false) registerAuthCookieSync(app);
235
+ }
236
+
237
+ /**
238
+ * 令牌双轨同步:登录 / 选商户 / 切换 / 续期成功时,响应体已返回 token(前端存 localStorage 走 Bearer 头),
239
+ * 这里再把它种进 HttpOnly Cookie,使**地址栏 GET(如调试面板 /api/<module>)也能自动携带、被鉴权中间件识别**——
240
+ * 从而同时满足「/api/* 全部强制鉴权」与「面板页可访问」。登出成功则清 Cookie。
241
+ *
242
+ * 与 registerAuthHook 配合:钩子仅对 `auth/*` 的成功响应生效(读响应体 data.accessToken),
243
+ * 不侵入业务逻辑、不改 service 签名;Bearer + Cookie 任一带上即通过。
244
+ */
245
+ export function registerAuthCookieSync(app: any): void {
246
+ if (!app || typeof app.addHook !== "function") return;
247
+ app.addHook("onSend", async (_request: any, reply: any, payload: any) => {
248
+ // payload 可能是字符串(已序列化)或对象(apirouter 派发多为对象),两种情况都处理
249
+ let body: any = payload;
250
+ if (typeof payload === "string") {
251
+ try {
252
+ body = JSON.parse(payload);
253
+ } catch {
254
+ return payload;
255
+ }
256
+ } else if (payload === null || typeof payload !== "object") {
257
+ return payload;
258
+ }
259
+ // 只处理认证模块的响应:cmd 形如 `auth/登录` 或带命名空间 `api/auth/登录`,
260
+ // 用「含 auth 段」匹配,兼容命名空间前缀(旧写法 startsWith("auth/") 在带前缀时永远不命中)
261
+ if (!String(body?.cmd ?? "").split("/").includes("auth")) return payload;
262
+ // access 唯一字段名 `accessToken`(旧字段名 `token` 已作废,不保留回落 ——
263
+ // 原则二:开发阶段不做向后兼容,改名即干净落地)。命中后种入 HttpOnly Cookie,
264
+ // 供地址栏 GET(调试面板 /api/<module>)自动携带。
265
+ const tok = body?.data?.accessToken ?? body?.accessToken;
266
+ if (tok) {
267
+ setAuthCookie(reply, String(tok));
268
+ // 刷新令牌同样双轨同步:登录 / 选代理商 / 切换 / 续期 等认证响应只要带了 refreshToken,
269
+ // 就把它种进 HttpOnly Cookie(Path 锁定 /api/auth 认证模块),浏览器在该模块下请求自动携带,防 XSS 偷 refreshToken。
270
+ const rft = body?.data?.refreshToken ?? body?.refreshToken;
271
+ if (rft) setRefreshCookie(reply, String(rft));
272
+ return payload;
273
+ }
274
+ if (body?.data?.ok === true || body?.ok === true) {
275
+ clearAuthCookie(reply);
276
+ clearRefreshCookie(reply);
277
+ }
278
+ return payload;
279
+ });
280
+ }
package/src/config.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 鉴权配置注入点(替代原 node-server 的 config.jwt)。
3
+ *
4
+ * 包不读环境变量,由宿主在启动时调用 `configureAuth({ privateKey, publicKey })` 注入——
5
+ * 这样包自身不依赖任何宿主的 config 模块,可被各服务各自注入各自的密钥。
6
+ *
7
+ * ## RS256 职责分离(这是选用非对称签名的唯一理由)
8
+ * - **签发方**(有登录入口的后台,如商户/代理商后台):注入 `privateKey` + `publicKey`;
9
+ * - **验签方**(无登录入口的对外服务,如支付/会员/收银):**只注入 `publicKey`**。
10
+ * 公钥只能验签不能签发 ⇒ 即便该服务被攻破,攻击者也无法伪造其它域的令牌。
11
+ *
12
+ * 对照 HS256:所有节点共用一把既能签又能验的密钥,任一节点沦陷即可伪造全局令牌。
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+
17
+ let privateKey = "";
18
+ let publicKey = "";
19
+ /** 验签公钥集合(多域);单 `publicKey` 也会规整成 `[{pem, dom:"merchant"}]` 存于此 */
20
+ let verifyEntries: Array<{ pem: string; dom: string }> = [];
21
+ let accessTtlMs = 30 * 60 * 1000;
22
+ let preTtlMs = 5 * 60 * 1000;
23
+ let refreshTtlMs = 7 * 24 * 60 * 60 * 1000;
24
+ /** 免鉴权路径白名单(宿主在启动时注入) */
25
+ let publicPrefixes: string[] = [];
26
+
27
+ export interface AuthConfigInput {
28
+ /** PEM 私钥(RS256 签发用)。可传 PEM 内容,也可传 `.pem` 文件路径;环境变量里的 `\n` 会自动还原为真换行 */
29
+ privateKey?: string;
30
+ /** PEM 公钥(RS256 验签用)。同上 */
31
+ publicKey?: string;
32
+ /**
33
+ * 多公钥验签(新增):一个服务要信任多个签发域时(例如商户后台既要验本域 merchant 令牌、
34
+ * 又要验代理商域 partner 令牌),传此数组,每项标注 `dom`(调用域,如 `"partner"` / `"merchant"`)。
35
+ * `verifyTokenDom` 逐把试签,返回命中公钥对应的 `dom`。
36
+ * 与旧版单 `publicKey` 互斥:传了 `publicKeys` 即以它为准(单 `publicKey` 等价于 `[{pem, dom:"merchant"}]`)。
37
+ */
38
+ publicKeys?: Array<{ pem: string; dom?: string }>;
39
+ /** access 有效期(默认 30 分钟) */
40
+ accessTtlMs?: number;
41
+ /** 预登录票据有效期(默认 5 分钟) */
42
+ preTtlMs?: number;
43
+ /** refresh 有效期(默认 7 天) */
44
+ refreshTtlMs?: number;
45
+ /**
46
+ * 免鉴权路径白名单(增量放行,不影响既有规则)。
47
+ * 仅用于把「真正公开」的端点(如联调测试接口 `/api/test`)加入放行列表;
48
+ * 切勿把业务接口(如 `/api/partner`)放入,否则等于将其降权为公开。
49
+ */
50
+ publicPrefixes?: string[];
51
+ }
52
+
53
+ /**
54
+ * 归一化为 PEM 内容:
55
+ * - 空值 → "";
56
+ * - 是存在的文件路径(含路径分隔符 / 以 .pem 结尾且磁盘上存在)→ 读文件内容;
57
+ * - 否则视为 PEM 文本,并把环境变量常见的字面 `\n` 还原为真换行。
58
+ */
59
+ function resolvePem(v: string | undefined): string {
60
+ const s = String(v ?? "").trim();
61
+ if (!s) return "";
62
+ const looksLikePath = /[\\/]/.test(s) || /\.pem$/i.test(s);
63
+ if (looksLikePath) {
64
+ try {
65
+ if (existsSync(s)) return readFileSync(s, "utf8").trim();
66
+ } catch {
67
+ /* 读不到就按 PEM 内容处理 */
68
+ }
69
+ }
70
+ return s.includes("\\n") ? s.replace(/\\n/g, "\n") : s;
71
+ }
72
+
73
+ /**
74
+ * 密钥版本号:每次**重新注入密钥**就自增。
75
+ * `jwt.ts` 缓存 KeyObject 时比对版本号,配置一变就重建 —— 否则「运行期换密钥」会被旧缓存静默吞掉。
76
+ */
77
+ let keyVersion = 0;
78
+
79
+ export function configureAuth(opts: AuthConfigInput): void {
80
+ if (opts.privateKey !== undefined) {
81
+ privateKey = resolvePem(opts.privateKey);
82
+ keyVersion++;
83
+ }
84
+ if (opts.publicKeys !== undefined) {
85
+ // 多域验签:每项解析 PEM 并标 dom(缺省 "merchant" 兼容旧语义)
86
+ verifyEntries = opts.publicKeys.map((k) => ({ pem: resolvePem(k.pem), dom: k.dom ?? "merchant" }));
87
+ keyVersion++;
88
+ } else if (opts.publicKey !== undefined) {
89
+ publicKey = resolvePem(opts.publicKey);
90
+ verifyEntries = [{ pem: publicKey, dom: "merchant" }];
91
+ keyVersion++;
92
+ }
93
+ if (opts.accessTtlMs !== undefined) accessTtlMs = opts.accessTtlMs;
94
+ if (opts.preTtlMs !== undefined) preTtlMs = opts.preTtlMs;
95
+ if (opts.refreshTtlMs !== undefined) refreshTtlMs = opts.refreshTtlMs;
96
+ if (opts.publicPrefixes !== undefined) publicPrefixes = opts.publicPrefixes;
97
+ }
98
+
99
+ /** 当前密钥版本号(供 jwt.ts 判断缓存是否过期) */
100
+ export function keyVersionOf(): number {
101
+ return keyVersion;
102
+ }
103
+
104
+ /** 当前免鉴权白名单(供 isPublicPath 使用) */
105
+ export function getPublicPrefixes(): string[] {
106
+ return publicPrefixes;
107
+ }
108
+
109
+ /** 路径是否命中免鉴权白名单(精确匹配或以 前缀/ 开头) */
110
+ export function isPublicPrefix(path: string): boolean {
111
+ return publicPrefixes.some((p) => path === p || path.startsWith(p + "/"));
112
+ }
113
+
114
+ export const authConfig = {
115
+ get privateKey() {
116
+ return privateKey;
117
+ },
118
+ get publicKey() {
119
+ return publicKey;
120
+ },
121
+ /** 验签公钥集合(含各自 dom);`verifyToken` / `verifyTokenDom` 据此逐把试签 */
122
+ get verifyEntries() {
123
+ return verifyEntries;
124
+ },
125
+ get accessTtlMs() {
126
+ return accessTtlMs;
127
+ },
128
+ get preTtlMs() {
129
+ return preTtlMs;
130
+ },
131
+ get refreshTtlMs() {
132
+ return refreshTtlMs;
133
+ },
134
+ };
135
+
136
+ /** 能否签发令牌(是否持有私钥)—— 只有登录方才应为 true */
137
+ export function canSign(): boolean {
138
+ return Boolean(privateKey);
139
+ }
140
+
141
+ /** 能否验签(是否持有公钥)—— 验签方至少要有公钥,否则所有令牌都会判定无效 */
142
+ export function canVerify(): boolean {
143
+ return Boolean(publicKey);
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./jwt.js";
2
+ export * from "./openSecret.js";
3
+ export * from "./auth-context.js";
4
+ export * from "./request-sign.js";
5
+ export { configureAuth, authConfig, canSign, canVerify } from "./config.js";
6
+ export type { AuthConfigInput } from "./config.js";
7
+ export * from "./permissions.js";