from __future__ import annotations import logging import time import httpx import jwt logger = logging.getLogger(__name__) JWT_EXPIRY_SECONDS = 600 TOKEN_REFRESH_MARGIN_SECONDS = 120 INSTALLATION_TOKEN_TTL = 3600 class GitHubAuth: def __init__(self, app_id: int, private_key: str): self.app_id = app_id self.private_key = private_key self._token_cache: dict[int, tuple[str, float]] = {} def _generate_jwt(self) -> str: now = int(time.time()) payload = { "iat": now - 60, "exp": now + JWT_EXPIRY_SECONDS, "iss": str(self.app_id), } return jwt.encode(payload, self.private_key, algorithm="RS256") async def get_installation_token(self, installation_id: int) -> str: now = time.time() cached = self._token_cache.get(installation_id) if cached and now < cached[1] - TOKEN_REFRESH_MARGIN_SECONDS: return cached[0] jwt_token = self._generate_jwt() headers = { "Authorization": f"Bearer {jwt_token}", "Accept": "application/vnd.github+json", } async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: resp = await client.post( f"https://api.github.com/app/installations/{installation_id}/access_tokens", headers=headers, ) resp.raise_for_status() data = resp.json() token = data["token"] expires_at_str = data.get("expires_at", "") expiry = now + INSTALLATION_TOKEN_TTL if expires_at_str: try: from datetime import datetime as dt parsed = dt.fromisoformat(expires_at_str.replace("Z", "+00:00")) expiry = parsed.timestamp() except (ValueError, OSError): pass self._token_cache[installation_id] = (token, expiry) return token def clear_cache(self): self._token_cache.clear() async def verify_credentials(account: dict, timeout_seconds: float = 10.0) -> dict | None: auth = GitHubAuth(account["app_id"], account["private_key"]) try: token = await auth.get_installation_token(account["installation_id"]) headers = { "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", } async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client: resp = await client.get("https://api.github.com/rate_limit", headers=headers) resp.raise_for_status() rate_data = resp.json() core = rate_data.get("resources", {}).get("core", {}) return { "ok": True, "rate_limit": core.get("limit", 5000), "rate_remaining": core.get("remaining", 5000), "rate_reset": core.get("reset", 0), } except httpx.HTTPStatusError as e: logger.warning("GitHub credential verification failed: HTTP %d", e.response.status_code) return None except Exception: logger.exception("GitHub credential verification error") return None