Hyeonsang Cho 161c3503d0
fix(github): build the App JWT off the loop and reuse the parsed key (#8203)
* 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.
2026-11-13 14:31:29 +08:00

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