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:
host committed 2026-10-26 16:47:55 +08:00
1 parent ee4b01c8f6
commit 25d9b3d938
7 files changed
+1060 -20

No files matched your search

+222
View File
@@ -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