From f3cb7265ff13424015db6397ba4f8988185bef7f Mon Sep 17 00:00:00 2001 From: Senor-Liu <71912659+Senor-Liu@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:49:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Agent=20JWT=20=E7=AD=BE=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/.vscode/launch.json | 1 + api/app/controllers/superAgentAccessToken.js | 31 ++++ api/app/routes/superAgent.js | 3 + api/app/utils/agentJwt.js | 153 +++++++++++++++++++ api/config/default.js | 8 + api/tests/agentJwt.test.mjs | 108 +++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 api/app/controllers/superAgentAccessToken.js create mode 100644 api/app/utils/agentJwt.js create mode 100644 api/tests/agentJwt.test.mjs diff --git a/api/.vscode/launch.json b/api/.vscode/launch.json index cb49f70..a68174c 100644 --- a/api/.vscode/launch.json +++ b/api/.vscode/launch.json @@ -115,6 +115,7 @@ "IMAGE_ASSETS_DATABASE_URL":"postgres://postgres:postgres@10.8.16.77:5434/deep_agent_db", /* 阿里云百炼 API KEY */ "DASHSCOPE_API_KEY": "", + "AUTH_JWT_VERIFICATION_KEY": "local-development-only-long-random-secret", } } ] diff --git a/api/app/controllers/superAgentAccessToken.js b/api/app/controllers/superAgentAccessToken.js new file mode 100644 index 0000000..2f40829 --- /dev/null +++ b/api/app/controllers/superAgentAccessToken.js @@ -0,0 +1,31 @@ +/** + * 文件作用: + * 在已完成 ai-center 登录后,签发 Agent 可验证的短时 JWT。 + * + * 职责: + * 1. 读取 tenderUserAuth 写入的当前用户。 + * 2. 用 query api 配置的密钥签发 JWT,sub 为 ai-center userid。 + * + * 不负责: + * 1. 代理 Agent 业务请求。 + * 2. 把 ai-center token 转发给 Agent。 + */ +'use strict'; + +const { issueAgentAccessToken } = require('../utils/agentJwt'); + +module.exports.issueAccessToken = async (ctx) => { + try { + const result = issueAgentAccessToken({ + userInfo: ctx.fs?.curUser?.userInfo, + config: ctx.app.fs.config?.superAgent?.jwt, + }); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.status = error.status || 500; + ctx.body = { + message: error.message || '签发 Agent 访问令牌失败', + }; + } +}; diff --git a/api/app/routes/superAgent.js b/api/app/routes/superAgent.js index f877b02..db5679c 100644 --- a/api/app/routes/superAgent.js +++ b/api/app/routes/superAgent.js @@ -1,6 +1,7 @@ 'use strict'; const superAgent = require('../controllers/superAgent'); +const superAgentAccessToken = require('../controllers/superAgentAccessToken'); const superAgentProtocol = require('../controllers/superAgentProtocol'); const Busboy = require('busboy'); const fs = require('fs'); @@ -118,6 +119,8 @@ const parseSuperAgentUploadFile = async (ctx, next) => { module.exports = function (app, router, conf) { const { tenderUserAuth } = app.middlewares; + router.post('/super-agent/access-token', tenderUserAuth, superAgentAccessToken.issueAccessToken, { content: '签发SuperAgent Agent JWT', visible: true }); + // SuperAgent v2 useStream 协议路由;与旧聊天接口并行保留,不做协议回退。 router.get('/super-agent/threads/:threadId/state', tenderUserAuth, superAgentProtocol.getState, { content: '获取SuperAgent v2线程状态', visible: true }); router.post('/super-agent/threads/:threadId/history', tenderUserAuth, superAgentProtocol.getHistory, { content: '获取SuperAgent v2线程历史', visible: true }); diff --git a/api/app/utils/agentJwt.js b/api/app/utils/agentJwt.js new file mode 100644 index 0000000..8dba6d4 --- /dev/null +++ b/api/app/utils/agentJwt.js @@ -0,0 +1,153 @@ +/** + * 文件作用: + * 为 SuperAgent 请求签发 Agent 可验证的短时 JWT。 + * + * 职责: + * 1. 从 ai-center 登录用户信息提取之前 X-User-Id 对应的 userid,写入 JWT sub。 + * 2. 用 query api 配置的密钥/私钥签名,算法与 Agent AUTH_* 对齐。 + * + * 不负责: + * 1. 验证 ai-center 登录 token。 + * 2. 把 X-User-Id 当作 Agent 身份。 + */ +'use strict'; + +const crypto = require('crypto'); + +const DEFAULT_EXPIRES_IN_SECONDS = 3600; +const SUPPORTED_ALGORITHMS = new Set(['HS256', 'RS256']); + +const toBase64Url = (value) => { + const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value); + return buffer.toString('base64url'); +}; + +const encodeJson = (value) => toBase64Url(JSON.stringify(value)); + +const unwrapUserInfo = (userInfo) => { + if (!userInfo || typeof userInfo !== 'object') return null; + if (userInfo.AIUserInfo && typeof userInfo.AIUserInfo === 'object') { + return userInfo.AIUserInfo; + } + if (userInfo.userInfo && typeof userInfo.userInfo === 'object') { + return userInfo.userInfo; + } + if (userInfo.user && typeof userInfo.user === 'object') { + return userInfo.user; + } + return userInfo; +}; + +const firstNonEmpty = (...values) => { + for (const value of values) { + if (value === undefined || value === null) continue; + const text = String(value).trim(); + if (text) return text; + } + return ''; +}; + +const createConfigError = (message) => { + const error = new Error(message); + error.status = 503; + return error; +}; + +const createRequestError = (message, status = 401) => { + const error = new Error(message); + error.status = status; + return error; +}; + +const normalizePem = (value) => String(value || '').replace(/\\n/g, '\n').trim(); + +const resolveJwtConfig = (rawConfig = {}) => { + const algorithm = String(rawConfig.algorithm || 'HS256').trim().toUpperCase(); + const expiresInSeconds = Number(rawConfig.expiresInSeconds || DEFAULT_EXPIRES_IN_SECONDS); + return { + algorithm, + secret: String(rawConfig.secret || '').trim(), + privateKey: normalizePem(rawConfig.privateKey), + issuer: String(rawConfig.issuer || '').trim(), + audience: String(rawConfig.audience || '').trim(), + expiresInSeconds: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 + ? Math.floor(expiresInSeconds) + : DEFAULT_EXPIRES_IN_SECONDS, + }; +}; + +const signJwt = (payload, config) => { + if (!SUPPORTED_ALGORITHMS.has(config.algorithm)) { + throw createConfigError(`不支持的 Agent JWT 算法:${config.algorithm}`); + } + + const header = { alg: config.algorithm, typ: 'JWT' }; + const signingInput = `${encodeJson(header)}.${encodeJson(payload)}`; + + if (config.algorithm === 'HS256') { + if (!config.secret) { + throw createConfigError('未配置 Agent JWT 签名密钥'); + } + const signature = crypto + .createHmac('sha256', config.secret) + .update(signingInput) + .digest(); + return `${signingInput}.${toBase64Url(signature)}`; + } + + if (!config.privateKey) { + throw createConfigError('未配置 Agent JWT 签名私钥'); + } + const signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(config.privateKey); + return `${signingInput}.${toBase64Url(signature)}`; +}; + +const resolveAgentUserId = (userInfo) => { + const identity = unwrapUserInfo(userInfo); + if (!identity) return ''; + return firstNonEmpty( + identity.localUserId, + identity.id, + identity.userId, + identity.userid, + identity.user_id, + identity.uid, + identity.pepUserId, + identity.pep_user_id, + identity.pepId, + identity.pep_id, + ); +}; + +const issueAgentAccessToken = ({ userInfo, config, now = Date.now() } = {}) => { + const jwtConfig = resolveJwtConfig(config); + const sub = resolveAgentUserId(userInfo); + if (!sub) { + throw createRequestError('认证用户信息无效'); + } + + const issuedAt = Math.floor(now / 1000); + const expiresAt = issuedAt + jwtConfig.expiresInSeconds; + const payload = { + sub, + iat: issuedAt, + exp: expiresAt, + }; + if (jwtConfig.issuer) payload.iss = jwtConfig.issuer; + if (jwtConfig.audience) payload.aud = jwtConfig.audience; + + return { + token: signJwt(payload, jwtConfig), + token_type: 'Bearer', + expires_in: jwtConfig.expiresInSeconds, + expires_at: expiresAt, + sub, + }; +}; + +module.exports = { + DEFAULT_EXPIRES_IN_SECONDS, + resolveAgentUserId, + resolveJwtConfig, + issueAgentAccessToken, +}; diff --git a/api/config/default.js b/api/config/default.js index 2522dbe..9b4d83e 100644 --- a/api/config/default.js +++ b/api/config/default.js @@ -407,6 +407,14 @@ module.exports = { }, superAgent: { baseUrl: process.env.FS_SUPER_AGENT_API || "http://localhost:8080", + jwt: { + algorithm: process.env.FS_SUPER_AGENT_JWT_ALGORITHM || "HS256", + secret: process.env.AUTH_JWT_VERIFICATION_KEY || "", + privateKey: process.env.FS_SUPER_AGENT_JWT_PRIVATE_KEY || "", + issuer: process.env.FS_SUPER_AGENT_JWT_ISSUER || "", + audience: process.env.FS_SUPER_AGENT_JWT_AUDIENCE || "", + expiresInSeconds: Number(process.env.FS_SUPER_AGENT_JWT_EXPIRES_IN || 3600), + }, }, analytics: { centerUrl: process.env.AI_CENTER_URL || "http://localhost:4001", diff --git a/api/tests/agentJwt.test.mjs b/api/tests/agentJwt.test.mjs new file mode 100644 index 0000000..f8a4595 --- /dev/null +++ b/api/tests/agentJwt.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + issueAgentAccessToken, + resolveAgentUserId, +} = require("../app/utils/agentJwt"); + +const decodeJwtPart = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8")); + +test("resolveAgentUserId matches previous X-User-Id order", () => { + assert.equal( + resolveAgentUserId({ + localUserId: "local-1", + id: "id-2", + pepUserId: "pep-88", + }), + "local-1", + ); + assert.equal( + resolveAgentUserId({ + id: "id-2", + pepUserId: "pep-88", + }), + "id-2", + ); +}); + +test("resolveAgentUserId reads nested AIUserInfo", () => { + assert.equal( + resolveAgentUserId({ + token: "ai-center-token", + AIUserInfo: { id: "center-user-9" }, + }), + "center-user-9", + ); +}); + +test("issueAgentAccessToken signs HS256 JWT with userid as sub", () => { + const secret = "local-development-only-long-random-secret"; + const issued = issueAgentAccessToken({ + userInfo: { id: "user-42" }, + config: { + algorithm: "HS256", + secret, + expiresInSeconds: 3600, + }, + now: 1_700_000_000_000, + }); + + assert.equal(issued.token_type, "Bearer"); + assert.equal(issued.sub, "user-42"); + assert.equal(issued.expires_in, 3600); + assert.equal(issued.expires_at, 1_700_000_000 + 3600); + + const [headerPart, payloadPart, signaturePart] = issued.token.split("."); + const header = decodeJwtPart(headerPart); + const payload = decodeJwtPart(payloadPart); + const expectedSignature = createHmac("sha256", secret) + .update(`${headerPart}.${payloadPart}`) + .digest("base64url"); + + assert.equal(header.alg, "HS256"); + assert.equal(payload.sub, "user-42"); + assert.equal(payload.iat, 1_700_000_000); + assert.equal(payload.exp, 1_700_000_000 + 3600); + assert.equal(signaturePart, expectedSignature); + assert.equal(payload.iss, undefined); + assert.equal(payload.aud, undefined); +}); + +test("issueAgentAccessToken writes issuer and audience when configured", () => { + const issued = issueAgentAccessToken({ + userInfo: { userId: "user-7" }, + config: { + algorithm: "HS256", + secret: "secret", + issuer: "query-api", + audience: "freesun-agent2", + }, + }); + const payload = decodeJwtPart(issued.token.split(".")[1]); + assert.equal(payload.iss, "query-api"); + assert.equal(payload.aud, "freesun-agent2"); +}); + +test("issueAgentAccessToken rejects missing userid", () => { + assert.throws( + () => issueAgentAccessToken({ + userInfo: {}, + config: { algorithm: "HS256", secret: "secret" }, + }), + (error) => error.status === 401 && /认证用户信息无效/.test(error.message), + ); +}); + +test("issueAgentAccessToken rejects missing HS256 secret", () => { + assert.throws( + () => issueAgentAccessToken({ + userInfo: { id: "user-1" }, + config: { algorithm: "HS256", secret: "" }, + }), + (error) => error.status === 503 && /未配置 Agent JWT 签名密钥/.test(error.message), + ); +});