관리 PAT 연동 및 마이그레이션
rc.44 로그인부터 작업 증명과 일회성 PAT 발급까지
사전 조건
관리 연동 주소는 https://api.beeaicloud.com입니다. 먼저 관리 인증을 읽으세요. PAT 생성과 관리에는 기존 PAT가 아닌 로그인 세션이 필요합니다. 예제는 자리표시자와 임시 변수를 사용합니다.
최종 권한을 정확히 연결
최상위 scope="access_token.generate"는 PAT 생성 작업이며 context.scopes는 PAT에 부여할 리소스 권한입니다. context에는 scopes와 expires_at만 있어야 합니다. 누락, 빈 객체, 잘못된 타입, 빈 권한, 추가 필드는 거부됩니다. 검증과 생성에서 같은 권한 집합과 Unix 초 만료 시각을 재사용하고 다시 계산하지 마세요. 양의 만료 시각은 생성 시점보다 최소 1시간 뒤여야 합니다. 생성 요청의 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 암호화, 계정 신원 확인, 같은 권한 연결을 처리합니다. password가 허용된 경우에만 사용하세요. 로그인 MFA나 CAPTCHA 등의 절차를 우회하면 안 됩니다. BB_PAT_SCOPES 기본값은 profile:read뿐입니다. 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에는 {"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 관리에는 로그인 세션이 필요합니다. |
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 | 만료 시각이 1시간 미만이거나 PAT 수 제한에 도달했습니다. |
회원가입과 초기화 완료의 차이
초기화는 계정 생성 → 로그인 → 작업 증명 → PAT 생성 → 안전한 저장과 연동 확인 순서입니다. 계정이 존재한다고 초기화가 끝난 것은 아닙니다. 기존 PAT 사용과 신규 발급은 다릅니다. 재개할 때 기존 계정을 유지하고 PAT 존재 여부를 확인한 뒤 미완료로 확인된 단계만 진행하세요. 발급 결과가 불명확하면 먼저 대조해야 합니다.
이 문서가 도움이 되었나요?