Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ tasks:
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_client --cov-append tests/ &&
cd ../testrail-client &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=testrail_client --cov-append tests/ &&
cd ../github-client &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=github_client --cov-append tests/ &&
cd ../.. &&
bash <(curl -s https://codecov.io/bash)"
metadata:
Expand Down
15 changes: 15 additions & 0 deletions libs/github-client/github_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from github_client.client import (
READ_ONLY_PERMISSIONS,
READ_WRITE_PERMISSIONS,
GitHubApp,
InstallationToken,
)
from github_client.config import GitHubAppSettings

__all__ = [
"READ_ONLY_PERMISSIONS",
"READ_WRITE_PERMISSIONS",
"GitHubApp",
"GitHubAppSettings",
"InstallationToken",
]
143 changes: 143 additions & 0 deletions libs/github-client/github_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""GitHub App authentication on top of githubkit.

githubkit signs the App JWT and mints, caches and refreshes installation tokens
for the clients returned here. :meth:`GitHubApp.installation_token` is for
callers that need the raw token instead, e.g. ``git push`` or a subprocess.
"""

import asyncio
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from types import MappingProxyType
from typing import Any, cast

from githubkit import AppAuthStrategy, AppInstallationAuthStrategy, GitHub
from githubkit.cache import MemCacheStrategy
from githubkit.utils import UNSET
from githubkit_schemas.latest.types import AppPermissionsType

from github_client.config import GitHubAppSettings

# Requesting a permission the App wasn't granted fails the mint with a 422, so
# keep these within what the App is configured with. Read-only, so no caller can
# widen them for the whole process.
READ_ONLY_PERMISSIONS: Mapping[str, str] = MappingProxyType(
{"contents": "read", "pull_requests": "read"}
)
READ_WRITE_PERMISSIONS: Mapping[str, str] = MappingProxyType(
{"contents": "write", "pull_requests": "write"}
)

_PERMISSION_NAMES = (
AppPermissionsType.__required_keys__ | AppPermissionsType.__optional_keys__
)


@dataclass(frozen=True)
class InstallationToken:
token: str = field(repr=False)
expires_at: datetime


class GitHubApp:
"""Authenticates as a GitHub App and its installations.

Extra keyword arguments are passed through to :class:`githubkit.GitHub`
(e.g. ``base_url``, ``timeout``).
"""

def __init__(
self,
settings: GitHubAppSettings,
*,
refresh_margin: timedelta = timedelta(minutes=5),
**github_options: Any,
):
self._settings = settings
self._auth = AppAuthStrategy(
settings.app_id, settings.private_key.get_secret_value()
)
# Our own cache rather than githubkit's process-wide default, so tokens
# are never shared between GitHubApp instances.
self._github_options = {"cache_strategy": MemCacheStrategy(), **github_options}
self._refresh_margin = refresh_margin
self._tokens: dict[tuple, InstallationToken] = {}
self._lock = asyncio.Lock()

def as_app(self) -> GitHub[AppAuthStrategy]:
"""A client authenticated as the App itself, for the ``/app`` endpoints."""
return GitHub(self._auth, **self._github_options)

def as_installation(
self,
installation_id: int | None = None,
*,
permissions: Mapping[str, str],
repositories: Sequence[str] | None = None,
) -> GitHub[AppInstallationAuthStrategy]:
"""A client authenticated as an installation, refreshing its own token.

``permissions`` and ``repositories`` (names, without the owner) narrow
the token below what the installation was granted.
"""
auth = self._auth.as_installation(
self._resolve_installation_id(installation_id),
repositories=UNSET if repositories is None else list(repositories),
permissions=_check_permissions(permissions),
)
return GitHub(auth, **self._github_options)

async def installation_token(
self,
installation_id: int | None = None,
*,
permissions: Mapping[str, str],
repositories: Sequence[str] | None = None,
) -> InstallationToken:
"""A raw installation token, reused until it is close to expiring."""
installation_id = self._resolve_installation_id(installation_id)
permissions = _check_permissions(permissions)
key = (
installation_id,
tuple(sorted(permissions.items())),
None if repositories is None else tuple(sorted(repositories)),
)
async with self._lock:
cached = self._tokens.get(key)
if cached and cached.expires_at - datetime.now(UTC) > self._refresh_margin:
return cached

# githubkit's ``rest`` namespace only holds a weak reference to its
# client, so the client must outlive the call.
github = self.as_app()
response = await github.rest.apps.async_create_installation_access_token(
installation_id,
permissions=permissions,
repositories=UNSET if repositories is None else list(repositories),
)
minted = InstallationToken(
token=response.parsed_data.token,
expires_at=datetime.fromisoformat(response.parsed_data.expires_at),
)
self._tokens[key] = minted
return minted

def _resolve_installation_id(self, installation_id: int | None) -> int:
installation_id = installation_id or self._settings.installation_id
if installation_id is None:
raise ValueError(
"No GitHub App installation ID: pass one or set GITHUB_INSTALLATION_ID."
)
return installation_id


def _check_permissions(permissions: Mapping[str, str]) -> AppPermissionsType:
# A token minted without permissions gets everything the App has, and
# githubkit silently drops names it doesn't know, so an empty set or a typo
# could widen the token instead of narrowing it.
if not permissions:
raise ValueError("permissions must name at least one permission")
if unknown := permissions.keys() - _PERMISSION_NAMES:
raise ValueError(f"Unknown GitHub App permissions: {sorted(unknown)}")
return cast(AppPermissionsType, dict(permissions))
51 changes: 51 additions & 0 deletions libs/github-client/github_client/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Configuration for :class:`GitHubApp`.

``GitHubAppSettings`` is a plain, validated config model with no env I/O, so it
can be embedded in a larger settings object without triggering a second
environment parse. Use :meth:`GitHubAppSettings.from_env` for standalone,
env-driven config.
"""

from typing import Annotated

from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class GitHubAppSettings(BaseModel):
# Never echo the private key back in a validation error.
model_config = ConfigDict(hide_input_in_errors=True)

app_id: Annotated[int, Field(gt=0)]
private_key: SecretStr
# Default installation for callers that don't pass one, e.g. a deployment
# that only ever talks to one org.
installation_id: Annotated[int | None, Field(gt=0)] = None

@field_validator("private_key")
@classmethod
def _check_private_key(cls, value: SecretStr) -> SecretStr:
# Env vars and secret stores often flatten the PEM's newlines to "\n".
pem = value.get_secret_value().replace("\\n", "\n").strip()
try:
key = load_pem_private_key(pem.encode(), password=None)
except ValueError, TypeError:
key = None
if not isinstance(key, RSAPrivateKey):
raise ValueError(
"must be the GitHub App's PEM-encoded RSA private key "
"(-----BEGIN RSA PRIVATE KEY----- ...)"
)
return SecretStr(pem)

@classmethod
def from_env(cls) -> GitHubAppSettings:
return _GitHubAppEnvSettings()


class _GitHubAppEnvSettings(GitHubAppSettings, BaseSettings):
model_config = SettingsConfigDict(
env_prefix="GITHUB_", extra="ignore", hide_input_in_errors=True
)
24 changes: 24 additions & 0 deletions libs/github-client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[project]
name = "github-client"
version = "0.1.0"
description = "Shared GitHub App auth for hackbot (githubkit-based)"
requires-python = ">=3.14"
dependencies = [
"cryptography>=42.0.0",
"githubkit[auth-app]>=0.16.0",
"pydantic-settings>=2.1.0",
]

[project.optional-dependencies]
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["github_client"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Loading