Files
2026-07-13 11:59:58 +08:00

230 lines
8.5 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.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
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()
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 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, load_app_private_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."""
headers = {
"Authorization": f"Bearer {mint_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. Tests reach for this between cases."""
_token_cache.clear()
_install_locks.clear()