6 changed files with 304 additions and 0 deletions
@ -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 访问令牌失败', |
||||
|
}; |
||||
|
} |
||||
|
}; |
||||
@ -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, |
||||
|
}; |
||||
@ -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…
Reference in new issue