169 lines
4.5 KiB
JavaScript
169 lines
4.5 KiB
JavaScript
/**
|
||
* services/zhuyuan-bridge/lib/github-auth.js
|
||
*
|
||
* GitHub App 鉴权模块
|
||
* - 用 App Private Key 生成 JWT
|
||
* - 获取 Installation Access Token
|
||
* - 查找仓库对应的 Installation ID
|
||
*
|
||
* 环境变量:
|
||
* GHAPP_APP_ID — GitHub App ID
|
||
* GHAPP_PRIVATE_KEY — GitHub App Private Key (PEM)
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const https = require('https');
|
||
|
||
/**
|
||
* 用 App Private Key 生成 JWT(不依赖 jsonwebtoken 库的 fallback 实现)
|
||
* 在 CI 环境中可能没有安装 jsonwebtoken,因此提供纯 Node.js 实现
|
||
*/
|
||
function generateJWT() {
|
||
const privateKey = process.env.GHAPP_PRIVATE_KEY;
|
||
const appId = process.env.GHAPP_APP_ID;
|
||
|
||
if (!privateKey || !appId) {
|
||
throw new Error('GHAPP_APP_ID 和 GHAPP_PRIVATE_KEY 环境变量必须设置');
|
||
}
|
||
|
||
// 尝试使用 jsonwebtoken 库
|
||
try {
|
||
const jwt = require('jsonwebtoken');
|
||
const payload = {
|
||
iat: Math.floor(Date.now() / 1000) - 60,
|
||
exp: Math.floor(Date.now() / 1000) + (10 * 60),
|
||
iss: appId
|
||
};
|
||
return jwt.sign(payload, privateKey, { algorithm: 'RS256' });
|
||
} catch (e) {
|
||
// jsonwebtoken 不可用时,使用 Node.js crypto 实现
|
||
const crypto = require('crypto');
|
||
|
||
function base64url(buf) {
|
||
return buf.toString('base64')
|
||
.replace(/=/g, '')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_');
|
||
}
|
||
|
||
const header = base64url(Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload = base64url(Buffer.from(JSON.stringify({
|
||
iat: now - 60,
|
||
exp: now + 600,
|
||
iss: appId
|
||
})));
|
||
|
||
const sigInput = header + '.' + payload;
|
||
const sign = crypto.createSign('RSA-SHA256');
|
||
sign.update(sigInput);
|
||
sign.end();
|
||
const signature = base64url(sign.sign(privateKey));
|
||
|
||
return sigInput + '.' + signature;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GitHub API 请求封装
|
||
*/
|
||
function githubRequest(method, apiPath, token, body) {
|
||
return new Promise((resolve, reject) => {
|
||
const options = {
|
||
hostname: 'api.github.com',
|
||
path: apiPath,
|
||
method: method,
|
||
headers: {
|
||
'User-Agent': 'zhuyuan-bridge/1.0',
|
||
'Accept': 'application/vnd.github+json',
|
||
'Authorization': token.startsWith('Bearer ')
|
||
? token
|
||
: `token ${token}`
|
||
}
|
||
};
|
||
if (body) {
|
||
options.headers['Content-Type'] = 'application/json';
|
||
}
|
||
|
||
const req = https.request(options, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
||
} catch (_) {
|
||
resolve({ status: res.statusCode, data: data });
|
||
}
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
if (body) req.write(JSON.stringify(body));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取某个 Installation 的操作令牌
|
||
* @param {number|string} installationId
|
||
* @returns {Promise<string>} Installation access token
|
||
*/
|
||
async function getInstallationToken(installationId) {
|
||
const jwtToken = generateJWT();
|
||
const result = await githubRequest(
|
||
'POST',
|
||
`/app/installations/${installationId}/access_tokens`,
|
||
`Bearer ${jwtToken}`
|
||
);
|
||
|
||
if (result.status !== 201 || !result.data.token) {
|
||
throw new Error(`获取 installation token 失败: ${result.status} ${JSON.stringify(result.data)}`);
|
||
}
|
||
|
||
return result.data.token;
|
||
}
|
||
|
||
/**
|
||
* 查找某个仓库的 Installation ID
|
||
* @param {string} owner
|
||
* @param {string} repo
|
||
* @returns {Promise<number|null>}
|
||
*/
|
||
async function findInstallation(owner, repo) {
|
||
const jwtToken = generateJWT();
|
||
const result = await githubRequest(
|
||
'GET',
|
||
'/app/installations',
|
||
`Bearer ${jwtToken}`
|
||
);
|
||
|
||
if (result.status !== 200 || !Array.isArray(result.data)) {
|
||
console.warn('⚠️ 获取 installations 列表失败:', result.status);
|
||
return null;
|
||
}
|
||
|
||
for (const inst of result.data) {
|
||
try {
|
||
const token = await getInstallationToken(inst.id);
|
||
const reposResult = await githubRequest(
|
||
'GET',
|
||
'/installation/repositories?per_page=100',
|
||
token
|
||
);
|
||
|
||
if (reposResult.data.repositories) {
|
||
const found = reposResult.data.repositories.some(
|
||
r => r.full_name === `${owner}/${repo}`
|
||
);
|
||
if (found) return inst.id;
|
||
}
|
||
} catch (e) {
|
||
console.warn(`⚠️ 检查 installation ${inst.id} 失败:`, e.message);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
module.exports = { generateJWT, getInstallationToken, findInstallation, githubRequest };
|