mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 15:50:35 +00:00
* fix(github): build the App JWT off the loop and reuse the parsed key mint_installation_token() runs on the channel manager's event loop. On every cold or expiring installation token it built the App JWT inline: it read GITHUB_APP_PRIVATE_KEY_PATH and passed the PEM to jwt.encode, which parses and validates the RSA key each time. For RSA-2048 the parse is ~45 ms of the ~50 ms loop stall, and it holds the GIL, so a worker thread alone does not help. Reuse the parsed key while the PEM text is unchanged (still read on every mint, so rotation needs no restart; parsed through PyJWT's own RS256 prepare_key, so formats and errors are unchanged; failures are not cached), and build the JWT in asyncio.to_thread so the file read and the signature leave the loop. Later mints drop from ~48 ms to ~1.4 ms of loop stall. * test(github): pin the single parse under concurrent mints Async mints call mint_app_jwt in worker threads, and the lock around the parsed-key cache is what keeps a cold race from parsing the key twice. Two threads now start mint_app_jwt behind a barrier while the first parse is held open; the key must be parsed once. The test-only reset also takes the lock, so a mint still parsing in a worker thread finishes before the reset instead of storing its key after it.
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""GitHub App authentication helpers.
|
|
|
|
This module owns the two-stage auth dance GitHub Apps use:
|
|
|
|
1. **App JWT** — signed with our App's RSA private key, lives 10 minutes
|
|
tops, identifies the App itself. Used only to mint installation
|
|
tokens.
|
|
|
|
2. **Installation access token** — short-lived (1 hour) OAuth-style token
|
|
scoped to one installation (one customer org / one repo set). Used as
|
|
``Authorization: token <…>`` on every REST call that does something
|
|
useful (post a comment, set a label, etc.).
|
|
|
|
The cache is in-process and intentionally simple: one dict keyed by
|
|
installation id, with a 55-minute TTL so we refresh well before the
|
|
60-minute GitHub limit. If two webhook deliveries land within a few
|
|
seconds we'll mint two tokens — that's fine and well under any
|
|
rate limit.
|
|
|
|
Building the App JWT reads the private key and signs with it, so the
|
|
async mint runs it in a worker thread. Parsing a PEM key is the costly
|
|
step (tens of ms for RSA-2048, holding the GIL, so a worker thread alone
|
|
would still stall the event loop), so the parsed key is reused for as
|
|
long as the configured PEM text stays the same.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import jwt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How long an App JWT lives. GitHub caps this at 10 minutes; we use 9 to
|
|
# leave headroom for the request itself.
|
|
_APP_JWT_TTL_SECONDS = 9 * 60
|
|
# Refresh installation tokens this many seconds before they expire.
|
|
_INSTALLATION_TOKEN_LEEWAY_SECONDS = 5 * 60
|
|
|
|
_APP_ID_ENV = "GITHUB_APP_ID"
|
|
_PRIVATE_KEY_PATH_ENV = "GITHUB_APP_PRIVATE_KEY_PATH"
|
|
_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY"
|
|
|
|
_GITHUB_API_BASE = "https://api.github.com"
|
|
|
|
|
|
class GitHubAppAuthError(RuntimeError):
|
|
"""Raised when GitHub App credentials are missing or invalid."""
|
|
|
|
|
|
@dataclass
|
|
class _CachedToken:
|
|
token: str
|
|
expires_at: float # epoch seconds
|
|
|
|
|
|
_token_cache: dict[int, _CachedToken] = {}
|
|
# Per-installation locks so a cold mint for installation A does not block
|
|
# concurrent lookups (cache hits OR independent cold mints) for any other
|
|
# installation. A single process-wide lock would serialize the entire
|
|
# fleet behind one slow GitHub /access_tokens roundtrip; this map gives
|
|
# each installation its own ~hundreds-of-ms HTTPS critical section while
|
|
# letting unrelated installations proceed concurrently.
|
|
_install_locks: dict[int, asyncio.Lock] = {}
|
|
# Guards the _install_locks map itself — only held while we look up /
|
|
# insert the per-installation lock, never while we hold one.
|
|
_install_locks_lock = asyncio.Lock()
|
|
|
|
# The most recently parsed App private key, as ``(pem_text, key)``. A
|
|
# changed PEM (a rotated env var or key file) is parsed again on the next
|
|
# mint. The lock keeps concurrent worker-thread mints from parsing twice.
|
|
_signing_key: tuple[str, Any] | None = None
|
|
_signing_key_lock = threading.Lock()
|
|
|
|
|
|
async def _lock_for(installation_id: int) -> asyncio.Lock:
|
|
"""Return the lock dedicated to ``installation_id``, creating on demand."""
|
|
async with _install_locks_lock:
|
|
lock = _install_locks.get(installation_id)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_install_locks[installation_id] = lock
|
|
return lock
|
|
|
|
|
|
def app_id() -> int:
|
|
"""Return the configured GitHub App id, or raise if unset.
|
|
|
|
Read fresh on every call so operators can rotate it without a process
|
|
restart.
|
|
"""
|
|
raw = os.environ.get(_APP_ID_ENV)
|
|
if not raw:
|
|
raise GitHubAppAuthError(f"{_APP_ID_ENV} is not set")
|
|
try:
|
|
return int(raw.strip())
|
|
except ValueError as exc:
|
|
raise GitHubAppAuthError(f"{_APP_ID_ENV}={raw!r} is not an integer") from exc
|
|
|
|
|
|
def load_app_private_key() -> str:
|
|
"""Return the App's RSA private key as a PEM string.
|
|
|
|
Reads from ``GITHUB_APP_PRIVATE_KEY`` (inline PEM) if set, else from
|
|
the path in ``GITHUB_APP_PRIVATE_KEY_PATH``. Inline takes precedence
|
|
so operators can roll a key by setting an env var instead of moving
|
|
files around in production.
|
|
"""
|
|
inline = os.environ.get(_PRIVATE_KEY_ENV)
|
|
if inline and inline.strip():
|
|
return inline
|
|
|
|
path = os.environ.get(_PRIVATE_KEY_PATH_ENV)
|
|
if not path:
|
|
raise GitHubAppAuthError(f"Neither {_PRIVATE_KEY_ENV} nor {_PRIVATE_KEY_PATH_ENV} is set")
|
|
p = Path(path).expanduser()
|
|
if not p.exists():
|
|
raise GitHubAppAuthError(f"{_PRIVATE_KEY_PATH_ENV} points to nonexistent file: {p}")
|
|
return p.read_text(encoding="utf-8")
|
|
|
|
|
|
def _app_signing_key() -> Any:
|
|
"""Return the App private key ready for RS256, parsing it only when it changed.
|
|
|
|
The PEM is still read on every call, so a rotated key is used by the
|
|
next mint without a restart.
|
|
"""
|
|
global _signing_key
|
|
pem = load_app_private_key()
|
|
with _signing_key_lock:
|
|
cached = _signing_key
|
|
if cached is not None and cached[0] == pem:
|
|
return cached[1]
|
|
# Same parsing (and the same errors) as passing the PEM to jwt.encode.
|
|
key = jwt.get_algorithm_by_name("RS256").prepare_key(pem)
|
|
_signing_key = (pem, key)
|
|
return key
|
|
|
|
|
|
def mint_app_jwt(*, now: float | None = None) -> str:
|
|
"""Sign a short-lived JWT identifying this App to GitHub.
|
|
|
|
Args:
|
|
now: Optional override for ``time.time()`` — tests use this.
|
|
|
|
Returns:
|
|
Signed RS256 JWT suitable for ``Authorization: Bearer <jwt>``.
|
|
"""
|
|
issued_at = int(now if now is not None else time.time())
|
|
payload = {
|
|
# GitHub recommends iat 60s in the past to tolerate clock skew.
|
|
"iat": issued_at - 60,
|
|
"exp": issued_at + _APP_JWT_TTL_SECONDS,
|
|
# iss must be a string in current pyjwt; GitHub accepts the
|
|
# numeric App id rendered as a decimal string.
|
|
"iss": str(app_id()),
|
|
}
|
|
return jwt.encode(payload, _app_signing_key(), algorithm="RS256")
|
|
|
|
|
|
async def _request_new_installation_token(
|
|
installation_id: int,
|
|
*,
|
|
client: httpx.AsyncClient | None = None,
|
|
) -> _CachedToken:
|
|
"""Hit ``POST /app/installations/{id}/access_tokens`` once."""
|
|
# Reading the key file and signing are blocking; keep them off the loop.
|
|
app_jwt = await asyncio.to_thread(mint_app_jwt)
|
|
headers = {
|
|
"Authorization": f"Bearer {app_jwt}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
url = f"{_GITHUB_API_BASE}/app/installations/{installation_id}/access_tokens"
|
|
|
|
async def _do(c: httpx.AsyncClient) -> _CachedToken:
|
|
resp = await c.post(url, headers=headers, timeout=15.0)
|
|
if resp.status_code != 201:
|
|
raise GitHubAppAuthError(f"Failed to mint installation token (status={resp.status_code} body={resp.text!r})")
|
|
data = resp.json()
|
|
token = data["token"]
|
|
# GitHub returns ISO8601 expires_at; we just bake in a 60-minute
|
|
# life and let the leeway handle the rest. Trusting the wall
|
|
# clock instead of parsing ISO is fine here.
|
|
expires_at = time.time() + 60 * 60
|
|
return _CachedToken(token=token, expires_at=expires_at)
|
|
|
|
if client is None:
|
|
async with httpx.AsyncClient() as c:
|
|
return await _do(c)
|
|
return await _do(client)
|
|
|
|
|
|
async def mint_installation_token(
|
|
installation_id: int,
|
|
*,
|
|
client: httpx.AsyncClient | None = None,
|
|
force_refresh: bool = False,
|
|
) -> str:
|
|
"""Return a valid installation access token, minting if necessary.
|
|
|
|
Concurrency: a per-installation :class:`asyncio.Lock` serializes mints
|
|
for the same installation (so two parallel cache-misses don't double-
|
|
mint), but mints for DIFFERENT installations proceed concurrently —
|
|
a slow GitHub /access_tokens call for installation A no longer
|
|
blocks lookups for installation B.
|
|
|
|
Cache hits take a lock-free fast path: we check the dict before
|
|
acquiring any lock, since :class:`asyncio.Lock` itself awaits the
|
|
event loop and there's no need to serialize a pure read on a value
|
|
that only this function ever mutates. The lock is re-acquired only
|
|
when we miss and need to mint, and we re-check the cache inside the
|
|
lock (double-checked locking) in case another coroutine just minted
|
|
while we were waiting.
|
|
|
|
Args:
|
|
installation_id: GitHub App installation id (per repo set).
|
|
client: Optional shared :class:`httpx.AsyncClient` — pass one in
|
|
if you have a long-lived client.
|
|
force_refresh: Skip the cache. Use after a 401 from the API.
|
|
|
|
Returns:
|
|
The token string. Caller adds ``Authorization: token <token>``.
|
|
"""
|
|
if installation_id <= 0:
|
|
raise GitHubAppAuthError(f"installation_id must be positive, got {installation_id!r}")
|
|
|
|
# Fast path: lock-free cache hit. The dict is mutated only inside
|
|
# the per-installation lock below, and Python dict reads of an
|
|
# existing key are atomic, so seeing a stale-but-still-valid entry
|
|
# here is fine (it's the same logic as the locked check, just
|
|
# earlier).
|
|
if not force_refresh:
|
|
cached = _token_cache.get(installation_id)
|
|
if cached is not None and cached.expires_at - _INSTALLATION_TOKEN_LEEWAY_SECONDS > time.time():
|
|
return cached.token
|
|
|
|
lock = await _lock_for(installation_id)
|
|
async with lock:
|
|
# Double-check: another coroutine for the same installation may
|
|
# have just minted while we were waiting for this lock.
|
|
cached = _token_cache.get(installation_id)
|
|
if cached is not None and not force_refresh and cached.expires_at - _INSTALLATION_TOKEN_LEEWAY_SECONDS > time.time():
|
|
return cached.token
|
|
|
|
fresh = await _request_new_installation_token(installation_id, client=client)
|
|
_token_cache[installation_id] = fresh
|
|
return fresh.token
|
|
|
|
|
|
def _clear_token_cache_for_tests() -> None:
|
|
"""Drop every cached token and the parsed key. Tests reach for this between cases."""
|
|
global _signing_key
|
|
_token_cache.clear()
|
|
_install_locks.clear()
|
|
# Under the lock, so a mint parsing in a worker thread finishes first
|
|
# instead of storing its key after the reset.
|
|
with _signing_key_lock:
|
|
_signing_key = None
|