App-token:推分支建 PR 改走 GitHub App installation token
- github_app:纯标准库 RS256 签 App JWT(PKCS#1/PKCS#8), 换 installation token(现换现用、不缓存),按仓库名查安装 记录,任一步失败回落 None - pr:app_token 路径 push 走 credential helper(token 只进 子进程环境),建 PR 走 REST API(读 default_branch 当 base); 不传 token 时 gh 路径不变 - server:/pr 端点按项目 repo_url 解析仓库名、现换 token, 响应加 auth_via=app|pat,换失败回落 PAT 不报错 - 测试:JWT/换 token/解析 9 个、pr App 路径 6 个、端点级 App 与 PAT 回落 2 个
This commit is contained in:
1 parent
ee4b01c8f6
commit
25d9b3d938
7 files changed
+1060
-20
No files matched your search
@@ -30,6 +30,7 @@ env secret,App webhook 用库里 secret,验签时两个都试(过渡
|
||||
注入 fake http 客户端做单测。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
@@ -410,3 +411,224 @@ def set_fix_review_task_id(conn, repo, pr_number, comment_id, task_id):
|
||||
(task_id, repo, pr_number, str(comment_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# -- App-token 阶段:JWT 与 installation token ---------------------------
|
||||
# 纯标准库 RS256:解析 PEM(PKCS#1 / PKCS#8)取 (n, e, d),
|
||||
# PKCS#1 v1.5 + SHA-256 签名。不依赖 openssl CLI、不落临时文件;
|
||||
# JWT 与 installation token 只在进程内出现,绝不打印、不进日志。
|
||||
# GitHub App 的 pem 格式:manifest 换回的通常是 PKCS#1
|
||||
#("BEGIN RSA PRIVATE KEY"),openssl 3 默认生成的是 PKCS#8
|
||||
#("BEGIN PRIVATE KEY"),两者都支持。
|
||||
|
||||
import base64 as _base64
|
||||
|
||||
_SHA256_DIGESTINFO_PREFIX = bytes.fromhex(
|
||||
"3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
def _b64url(data):
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
return _base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _der_read(data, pos):
|
||||
"""读一个 DER TLV:返回 (tag, value, next_pos)。"""
|
||||
tag = data[pos]
|
||||
pos += 1
|
||||
length = data[pos]
|
||||
pos += 1
|
||||
if length & 0x80:
|
||||
nbytes = length & 0x7F
|
||||
if nbytes == 0 or nbytes > 4:
|
||||
raise AppError("unsupported DER length",
|
||||
reason="bad_key")
|
||||
length = int.from_bytes(data[pos:pos + nbytes], "big")
|
||||
pos += nbytes
|
||||
return tag, data[pos:pos + length], pos + length
|
||||
|
||||
|
||||
def _der_sequence_items(der):
|
||||
"""SEQUENCE 的子项列表(每项为 (tag, value))。"""
|
||||
tag, value, _ = _der_read(der, 0)
|
||||
if tag != 0x30:
|
||||
raise AppError("expected DER SEQUENCE", reason="bad_key")
|
||||
items = []
|
||||
pos = 0
|
||||
while pos < len(value):
|
||||
t, v, pos = _der_read(value, pos)
|
||||
items.append((t, v))
|
||||
return items
|
||||
|
||||
|
||||
def _parse_pkcs1_der(der):
|
||||
"""PKCS#1 RSAPrivateKey DER -> (n, e, d)。"""
|
||||
items = _der_sequence_items(der)
|
||||
if len(items) < 4:
|
||||
raise AppError("short PKCS#1 key", reason="bad_key")
|
||||
n = int.from_bytes(items[1][1], "big")
|
||||
e = int.from_bytes(items[2][1], "big")
|
||||
d = int.from_bytes(items[3][1], "big")
|
||||
if n <= 0 or e <= 0 or d <= 0:
|
||||
raise AppError("invalid RSA parameters", reason="bad_key")
|
||||
return n, e, d
|
||||
|
||||
|
||||
def _parse_rsa_private_key(pem):
|
||||
"""解析 PEM 私钥 -> (n, e, d),PKCS#1 与 PKCS#8 都支持。"""
|
||||
if not pem or not isinstance(pem, str):
|
||||
raise AppError("private key required", reason="bad_key")
|
||||
body = "".join(
|
||||
line.strip() for line in pem.splitlines()
|
||||
if line.strip() and not line.strip().startswith("-----"))
|
||||
try:
|
||||
der = _base64.b64decode(body)
|
||||
except Exception as exc:
|
||||
raise AppError("private key is not valid base64: %s" % (exc,),
|
||||
reason="bad_key") from exc
|
||||
if "RSA PRIVATE KEY" in pem:
|
||||
return _parse_pkcs1_der(der)
|
||||
if "PRIVATE KEY" in pem:
|
||||
items = _der_sequence_items(der)
|
||||
if len(items) < 3 or items[2][0] != 0x04:
|
||||
raise AppError("unexpected PKCS#8 shape", reason="bad_key")
|
||||
return _parse_pkcs1_der(items[2][1])
|
||||
raise AppError("unsupported private key format", reason="bad_key")
|
||||
|
||||
|
||||
def rsa_sign_sha256(pem, message):
|
||||
"""RS256 签名:返回签名 bytes(长度 = 模长字节数)。"""
|
||||
n, _e, d = _parse_rsa_private_key(pem)
|
||||
if isinstance(message, str):
|
||||
message = message.encode("utf-8")
|
||||
digest = hashlib.sha256(message).digest()
|
||||
t = _SHA256_DIGESTINFO_PREFIX + digest
|
||||
em_len = (n.bit_length() + 7) // 8
|
||||
if em_len < len(t) + 11:
|
||||
raise AppError("RSA key too small for SHA-256", reason="bad_key")
|
||||
em = b"\x00\x01" + b"\xff" * (em_len - len(t) - 3) + b"\x00" + t
|
||||
m = int.from_bytes(em, "big")
|
||||
s = pow(m, d, n)
|
||||
return s.to_bytes(em_len, "big")
|
||||
|
||||
|
||||
def mint_app_jwt(app_id, pem, now=None, ttl_seconds=540):
|
||||
"""签发 App JWT:header alg=RS256,有效期默认 9 分钟
|
||||
(GitHub 上限 10 分钟,iat 回拨 60 秒防时钟 skew)。
|
||||
|
||||
now 只为单测注入时间。pem 非法时抛 AppError(调用方据此
|
||||
回落 PAT,不静默用坏 token 去撞 GitHub)。
|
||||
"""
|
||||
if app_id is None or str(app_id) == "":
|
||||
raise AppError("app_id required", reason="bad_key")
|
||||
if now is None:
|
||||
now = time.time()
|
||||
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"},
|
||||
separators=(",", ":")))
|
||||
payload = _b64url(json.dumps(
|
||||
{"iat": int(now) - 60, "exp": int(now) + int(ttl_seconds),
|
||||
"iss": str(app_id)},
|
||||
separators=(",", ":")))
|
||||
signing_input = header + "." + payload
|
||||
signature = rsa_sign_sha256(pem, signing_input.encode("ascii"))
|
||||
return signing_input + "." + _b64url(signature)
|
||||
|
||||
|
||||
def _github_api_request(method, url, token, payload, http_client):
|
||||
"""调 GitHub API:返回 (status, dict)。
|
||||
|
||||
http_client 注入约定(单测用):``http_client(method, url,
|
||||
headers, body_bytes_or_None) -> (status, dict)``。真实路径
|
||||
用 urllib,HTTPError 也解析 body(GitHub 的错误体是 JSON,
|
||||
调用方按 status 判断,不靠异常)。
|
||||
"""
|
||||
headers = {"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "codex-app-server"}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
if http_client is not None:
|
||||
result = http_client(method, url, headers, body)
|
||||
if not (isinstance(result, tuple) and len(result) == 2):
|
||||
raise AppError("http_client must return (status, dict)",
|
||||
reason="token_exchange_failed")
|
||||
return result
|
||||
request = urllib.request.Request(url, data=body, method=method,
|
||||
headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
status = e.code
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
parsed = {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
return status, parsed
|
||||
|
||||
|
||||
def exchange_installation_token(app_id, pem, installation_id,
|
||||
http_client=None, now=None):
|
||||
"""换 installation token:返回 {token, expires_at, permissions,
|
||||
repository_selection}。token 有效期 1 小时,调用方现换现用,
|
||||
不缓存、不存库。换失败抛 AppError(调用方回落 PAT)。"""
|
||||
if not installation_id:
|
||||
raise AppError("installation_id required",
|
||||
reason="token_exchange_failed")
|
||||
jwt = mint_app_jwt(app_id, pem, now=now)
|
||||
url = ("https://api.github.com/app/installations/%s/access_tokens"
|
||||
% installation_id)
|
||||
status, data = _github_api_request("POST", url, jwt, None, http_client)
|
||||
if status != 201 or not data.get("token"):
|
||||
raise AppError(
|
||||
"installation token exchange failed: %s %r"
|
||||
% (status, data.get("message")),
|
||||
reason="token_exchange_failed")
|
||||
return {"token": data["token"], "expires_at": data.get("expires_at"),
|
||||
"permissions": data.get("permissions"),
|
||||
"repository_selection": data.get("repository_selection")}
|
||||
|
||||
|
||||
def find_installation_for_repo(conn, repo_full_name):
|
||||
"""查仓库对应的 App installation id:无记录返回 None
|
||||
(调用方回落 PAT,不报错——没装 App 的仓库走老路)。"""
|
||||
if not repo_full_name:
|
||||
return None
|
||||
_ensure_installations_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT installation_id FROM github_app_installations"
|
||||
" WHERE repo_full_name = ? ORDER BY created_at LIMIT 1",
|
||||
(repo_full_name,),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def resolve_app_token(conn, repo_full_name, http_client=None):
|
||||
"""给仓库解析 App installation token:成功返回 token 字符串,
|
||||
任一步缺失或失败返回 None(调用方回落 PAT,不抛错)。
|
||||
|
||||
步骤:App 配置(库里)-> 安装记录(仓库名查 installation
|
||||
id)-> JWT -> 换 token。token 与 JWT 只在进程内出现。
|
||||
"""
|
||||
try:
|
||||
config = get_app_config(conn)
|
||||
if config is None or not config.get("pem"):
|
||||
return None
|
||||
installation_id = find_installation_for_repo(conn, repo_full_name)
|
||||
if installation_id is None:
|
||||
return None
|
||||
result = exchange_installation_token(
|
||||
config.get("app_id"), config.get("pem"), installation_id,
|
||||
http_client=http_client)
|
||||
return result["token"]
|
||||
except AppError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
Reference in new issue
Block a user