"""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 ``. """ 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 ``. """ 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