Browse Source

feat: Agent JWT 签发

master
Senor-Liu 1 month ago
parent
commit
f3cb7265ff
  1. 1
      api/.vscode/launch.json
  2. 31
      api/app/controllers/superAgentAccessToken.js
  3. 3
      api/app/routes/superAgent.js
  4. 153
      api/app/utils/agentJwt.js
  5. 8
      api/config/default.js
  6. 108
      api/tests/agentJwt.test.mjs

1
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",
}
}
]

31
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 访问令牌失败',
};
}
};

3
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 });

153
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,
};

8
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",

108
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),
);
});
Loading…
Cancel
Save