管理 PAT 接入与旧版迁移
完整演示 rc.44 的登录、权限选择、密码操作证明及一次性 PAT 签发
接入前提
本指南面向管理接口集成,使用 https://api.beeaicloud.com。先阅读管理 API 鉴权。创建与管理 PAT 必须持有登录会话,不接受已有 PAT 代替。所有示例均为占位或临时变量,未在生产代建账号或 Token。
验证上下文必须绑定最终授权
顶层 scope="access_token.generate" 表示“创建 PAT”操作;context.scopes 是拟授予 PAT 的资源权限,两者不同。context 必须恰好包含 scopes 与 expires_at;缺失、空对象、错误类型、空权限或额外字段会被拒绝。验证与创建须复用同一组权限和同一个 Unix 秒过期时间,不能在第二步重新计算。建议设置合理到期时间;正值至少在创建时晚于当前时间一小时。创建请求另有 name(去空白后 1–64 个字符),它不属于验证 context。
{"method":"password","scope":"access_token.generate","context":{"scopes":["profile:read"],"expires_at":1794679200},"password_encrypted":"<encrypted-password>","encryption_key_id":"<current-kid>"}密码方式完整示例
下面是 Node.js 示例,覆盖当前公钥、v2 密码加密、账号身份核对和相同 grant 绑定。仅适用于验证方式查询明确允许 password 的账号;遇到登录二次验证、验证码或其他方法时应完成相应交互,不绕过。BB_PAT_SCOPES 默认只有 profile:read,请依据 catalog 配置实际业务所需的最小权限。示例不打印 PAT;在进程结束前将 pat 接入你的秘密存储。
import { constants, createCipheriv, publicEncrypt, randomBytes } from 'node:crypto';
const base = 'https://api.beeaicloud.com';
const username = process.env.BB_USERNAME;
const password = process.env.BB_PASSWORD;
const expectedUserID = process.env.BB_EXPECTED_USER_ID;
if (!username || !password || !expectedUserID) throw new Error('Missing account configuration');
async function call(path, { method = 'GET', access, proof, body } = {}) {
const response = await fetch(base + path, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(access ? { Authorization: `Bearer ${access}` } : {}),
...(proof ? { 'X-Security-Proof': proof } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
redirect: 'error',
});
const result = await response.json();
if (!response.ok || result.success !== true) {
const code = result.code ?? result.data?.code ?? 'REQUEST_REJECTED';
throw new Error(`${method} ${path}: HTTP ${response.status} ${code}`);
}
return result.data;
}
async function passwordFields() {
const key = await call('/api/user/login/encryption-key');
if (!key.enabled) return { password };
const aesKey = randomBytes(32);
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', aesKey, nonce);
cipher.setAAD(Buffer.from(`password-v2:${key.kid}`));
const ciphertext = Buffer.concat([
cipher.update(password, 'utf8'), cipher.final(), cipher.getAuthTag(),
]);
const wrappedKey = publicEncrypt({
key: key.public_key, padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256', oaepLabel: Buffer.from('password-v2'),
}, aesKey);
return {
password_encrypted: ['v2', wrappedKey.toString('base64'),
nonce.toString('base64'), ciphertext.toString('base64')].join('.'),
encryption_key_id: key.kid,
};
}
const login = await call('/api/user/login', {
method: 'POST', body: { username, ...await passwordFields() },
});
if (login.require_verification || !login.access_token) {
throw new Error('Complete the required sign-in verification flow first');
}
const access = login.access_token;
const profile = await call('/api/user/self', { access });
if (String(profile.id) !== expectedUserID || profile.username !== username) {
throw new Error('Account identity mismatch');
}
const catalog = await call('/api/user/access_tokens/catalog', { access });
const grantable = new Set(catalog.groups.flatMap(group => group.resources.flatMap(
resource => resource.actions.map(action => `${resource.resource}:${action.action}`),
)));
const grant = {
scopes: JSON.parse(process.env.BB_PAT_SCOPES ?? '["profile:read"]'),
expires_at: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
};
if (!Array.isArray(grant.scopes) || grant.scopes.length === 0 ||
grant.scopes.some(scope => !grantable.has(scope))) {
throw new Error('Select grantable permissions from the catalog');
}
const methods = await call('/api/verify/methods?scope=access_token.generate', { access });
if (!methods.methods.some(method => method.method === 'password' && method.available)) {
throw new Error('Use an available verification method for this account');
}
const verification = await call('/api/verify', {
method: 'POST', access,
body: { method: 'password', scope: 'access_token.generate',
context: grant, ...await passwordFields() },
});
const created = await call('/api/user/access_tokens', {
method: 'POST', access, proof: verification.proof_token,
body: { name: 'business-integration', ...grant },
});
const pat = created.token;
// Store pat in your secret manager here. Never log it or blindly retry creation.
console.log({ id: created.item.id, expires_at: created.item.expires_at });读取、改权与撤销
列表 GET /api/user/access_tokens 不返回明文。创建响应的 data.token 只返回一次,应保密存储,不能从 token_ref 或 token_hint 恢复。PATCH /api/user/access_tokens/{id} 的 name 必填;只改名称不需证明,提交 scopes 则先为 access_token.update 验证 {"token_id":123,"scopes":["profile:read"]}。撤销使用 DELETE /api/user/access_tokens/{id},先为 access_token.revoke 验证 {"token_id":123}。撤销旧 PAT 使用 DELETE /api/user/access_tokens/legacy,context 为 {"legacy":true}。每项写操作各用一次新证明。
常见错误定位
下面的错误码来自 rc.44 实现。不要只用 HTTP 状态判断成功,也不要把 password_proof 的调用阶段名直接解释成密码错误。
| 错误码 | 含义与检查项 |
|---|---|
SECURITY_CONTEXT_INVALID | HTTP 400;context 缺失或不符合目标操作的严格结构。 |
SECURITY_VERIFICATION_FAILED | 密码、密文或验证代码校验失败;可能是 HTTP 200 + success=false。 |
SECURITY_VERIFICATION_FLOW_REQUIRED | HTTP 400;所选方法必须使用专用交互验证流程。 |
AUTH_SESSION_REQUIRED | HTTP 403;PAT 管理要求登录会话,不能使用 PAT 代替。 |
SECURITY_PROOF_CONTEXT_MISMATCH | 证明与创建/改权/撤销请求的具体参数不一致。 |
SECURITY_PROOF_EXPIRED / SECURITY_PROOF_CONSUMED | 证明已过期或已被消费;核对操作结果后重新验证。 |
ACCESS_TOKEN_SCOPE_INVALID / ACCESS_TOKEN_SCOPE_FORBIDDEN | 权限名无效或当前账号不可授予该权限。 |
ACCESS_TOKEN_EXPIRY_INVALID / ACCESS_TOKEN_LIMIT | 到期时间不足一小时,或已达到当前用户的 PAT 数量上限。 |
注册成功不等于初始化完成
企业开户通常分为“创建账号 → 登录 → 获取操作证明 → 创建 PAT → 保存并验证集成配置”。账号存在或已能登录,只说明前两步完成。已有旧 PAT 的企业可继续访问,不代表新企业能用旧请求完成签发。恢复失败初始化时保留已有账号,先核对是否已存在 PAT,再从已确认未完成的步骤续接;未知签发结果必须先对账。
这篇文档对您有帮助吗?