-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithub_app_token.py
More file actions
109 lines (95 loc) · 4.12 KB
/
Copy pathgithub_app_token.py
File metadata and controls
109 lines (95 loc) · 4.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
"""Mint a short-lived GitHub App installation token without persisting it."""
from __future__ import annotations
import base64
import json
import os
import re
import stat
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
API_VERSION = "2022-11-28"
class AppTokenError(RuntimeError):
"""Raised when the local App credentials cannot mint an installation token."""
def _base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def validate_private_key_file(path: Path) -> None:
try:
details = path.stat()
except OSError as exc:
raise AppTokenError(f"GitHub App private key is not readable: {path}") from exc
if not stat.S_ISREG(details.st_mode):
raise AppTokenError("GitHub App private key path must be a regular file")
if details.st_uid != os.getuid():
raise AppTokenError("GitHub App private key must be owned by the current user")
if stat.S_IMODE(details.st_mode) & 0o077:
raise AppTokenError("GitHub App private key must not be group/world readable")
def build_app_jwt(app_id: str, private_key_file: Path, now: int | None = None) -> str:
app_id = app_id.strip()
if not re.fullmatch(r"\d+", app_id):
raise AppTokenError("GITHUB_APP_ID must be numeric")
validate_private_key_file(private_key_file)
now = int(time.time() if now is None else now)
header = _base64url(json.dumps({"alg": "RS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = _base64url(
json.dumps({"iat": now - 60, "exp": now + 540, "iss": int(app_id)}, separators=(",", ":")).encode()
)
signing_input = f"{header}.{payload}".encode("ascii")
try:
signed = subprocess.run(
["openssl", "dgst", "-sha256", "-sign", str(private_key_file)],
input=signing_input,
capture_output=True,
check=False,
)
except OSError as exc:
raise AppTokenError("openssl is required to sign the GitHub App JWT") from exc
if signed.returncode:
raise AppTokenError("openssl could not sign the GitHub App JWT")
return f"{header}.{payload}.{_base64url(signed.stdout)}"
def mint_installation_token(
app_id: str,
installation_id: str,
private_key_file: Path,
repositories: list[str] | None = None,
) -> dict[str, Any]:
installation_id = installation_id.strip()
if not re.fullmatch(r"\d+", installation_id):
raise AppTokenError("GITHUB_INSTALLATION_ID must be numeric")
jwt = build_app_jwt(app_id, private_key_file)
body: dict[str, Any] = {}
if repositories:
body["repositories"] = repositories
request = urllib.request.Request(
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
data=json.dumps(body).encode("utf-8"),
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {jwt}",
"X-GitHub-Api-Version": API_VERSION,
"User-Agent": "maintainer-agent",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
payload = json.loads(exc.read().decode("utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
message = payload.get("message") if isinstance(payload, dict) else None
detail = f" (HTTP {exc.code}{': ' + str(message) if message else ''})"
raise AppTokenError(f"GitHub rejected the installation token request{detail}") from exc
except (OSError, json.JSONDecodeError) as exc:
raise AppTokenError("GitHub rejected the installation token request") from exc
token = data.get("token") if isinstance(data, dict) else None
expires_at = data.get("expires_at") if isinstance(data, dict) else None
if not isinstance(token, str) or not token.strip():
raise AppTokenError("GitHub returned no installation token")
return {"token": token, "expires_at": expires_at}