|
| 1 | +"""GitHub App installation tokens, for agents that clone, push, or open PRs. |
| 2 | +
|
| 3 | +The pattern: hold no personal access token. Authenticate as a GitHub App and |
| 4 | +mint a short-lived installation token whenever one is needed. Tokens live one |
| 5 | +hour — plenty for a clone or a `gh pr create`, useless to an attacker who |
| 6 | +exfiltrates one from a log. |
| 7 | +
|
| 8 | +This belongs in the plugin because every agent otherwise carries its own copy |
| 9 | +of the same fifty lines: sign an RS256 JWT as the app, trade it for an |
| 10 | +installation token, splice it into a clone URL. The webhook side of a GitHub |
| 11 | +agent already imports this package, so the auth side comes from it too. |
| 12 | +
|
| 13 | +Inputs, all injected as Flyte secrets (or passed explicitly): |
| 14 | +
|
| 15 | + GITHUB_APP_ID the app's numeric id |
| 16 | + GITHUB_APP_INSTALLATION_ID the installation's numeric id |
| 17 | + GITHUB_APP_PRIVATE_KEY the app's PEM private key |
| 18 | +
|
| 19 | +`GITHUB_TOKEN` and `GH_TOKEN` are honored as fallbacks so a deployment can |
| 20 | +migrate one secret at a time; once the app secrets exist the fallback never |
| 21 | +fires. |
| 22 | +
|
| 23 | +`PyJWT[crypto]` signs the app JWT, so a webhook-only install stays lean: |
| 24 | +
|
| 25 | +```bash |
| 26 | +pip install "flyteplugins-github[auth]" |
| 27 | +``` |
| 28 | +""" |
| 29 | + |
| 30 | +from __future__ import annotations |
| 31 | + |
| 32 | +import json |
| 33 | +import logging |
| 34 | +import os |
| 35 | +import time |
| 36 | +import urllib.request |
| 37 | +from typing import Any |
| 38 | + |
| 39 | +logger = logging.getLogger(__name__) |
| 40 | + |
| 41 | +GITHUB_API = "https://api.github.com" |
| 42 | +#: App tokens authenticate with this literal username in clone URLs. |
| 43 | +GIT_USERNAME = "x-access-token" |
| 44 | + |
| 45 | +#: Environment variables the app credentials default to. |
| 46 | +DEFAULT_APP_ID_ENV = "GITHUB_APP_ID" |
| 47 | +DEFAULT_INSTALLATION_ID_ENV = "GITHUB_APP_INSTALLATION_ID" |
| 48 | +DEFAULT_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY" |
| 49 | + |
| 50 | +#: Plain-token fallbacks, checked in order when the app credentials are absent. |
| 51 | +FALLBACK_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN") |
| 52 | + |
| 53 | + |
| 54 | +def _post_json(url: str, *, bearer: str) -> dict[str, Any]: |
| 55 | + """POST to the GitHub API with a bearer credential and return the JSON.""" |
| 56 | + request = urllib.request.Request( |
| 57 | + url, |
| 58 | + method="POST", |
| 59 | + headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"}, |
| 60 | + ) |
| 61 | + with urllib.request.urlopen(request, timeout=30) as response: |
| 62 | + return json.loads(response.read().decode("utf-8")) |
| 63 | + |
| 64 | + |
| 65 | +def mint_installation_token( |
| 66 | + *, |
| 67 | + app_id: str | None = None, |
| 68 | + installation_id: str | None = None, |
| 69 | + private_key: str | None = None, |
| 70 | +) -> str | None: |
| 71 | + """A fresh installation token, or None with a logged reason. |
| 72 | +
|
| 73 | + None means "proceed unauthenticated or not at all" — treat it the way a |
| 74 | + missing token is treated today, so a half-configured deployment degrades |
| 75 | + instead of crashing. Synchronous, one HTTPS round trip: call through |
| 76 | + `asyncio.to_thread` from handlers and other async code. |
| 77 | +
|
| 78 | + Args: |
| 79 | + app_id: The app's numeric id; otherwise read from `GITHUB_APP_ID`. |
| 80 | + installation_id: The installation's numeric id; otherwise read from |
| 81 | + `GITHUB_APP_INSTALLATION_ID`. |
| 82 | + private_key: The app's PEM private key; otherwise read from |
| 83 | + `GITHUB_APP_PRIVATE_KEY`. |
| 84 | + """ |
| 85 | + app_id = app_id or os.environ.get(DEFAULT_APP_ID_ENV) |
| 86 | + installation_id = installation_id or os.environ.get(DEFAULT_INSTALLATION_ID_ENV) |
| 87 | + private_key = private_key or os.environ.get(DEFAULT_PRIVATE_KEY_ENV) |
| 88 | + |
| 89 | + if not (app_id and installation_id and private_key): |
| 90 | + for env in FALLBACK_TOKEN_ENVS: |
| 91 | + fallback = os.environ.get(env) |
| 92 | + if fallback: |
| 93 | + logger.info("GitHub App credentials not set; using %s fallback", env) |
| 94 | + return fallback |
| 95 | + logger.warning( |
| 96 | + "Neither the %s/%s/%s secrets nor a fallback token (%s) are set; " |
| 97 | + "authenticated GitHub operations will be skipped", |
| 98 | + DEFAULT_APP_ID_ENV, |
| 99 | + DEFAULT_INSTALLATION_ID_ENV, |
| 100 | + DEFAULT_PRIVATE_KEY_ENV, |
| 101 | + "/".join(FALLBACK_TOKEN_ENVS), |
| 102 | + ) |
| 103 | + return None |
| 104 | + |
| 105 | + try: |
| 106 | + import jwt # PyJWT[crypto] |
| 107 | + except ModuleNotFoundError as exc: # pragma: no cover - depends on extras |
| 108 | + raise ModuleNotFoundError( |
| 109 | + "PyJWT is not installed. Install 'flyteplugins-github[auth]' to mint GitHub App tokens." |
| 110 | + ) from exc |
| 111 | + |
| 112 | + now = int(time.time()) |
| 113 | + # iat is backdated 60s because GitHub rejects JWTs it considers issued in |
| 114 | + # the future, and clocks drift. |
| 115 | + app_jwt = jwt.encode({"iat": now - 60, "exp": now + 600, "iss": app_id}, private_key, algorithm="RS256") |
| 116 | + try: |
| 117 | + data = _post_json(f"{GITHUB_API}/app/installations/{installation_id}/access_tokens", bearer=app_jwt) |
| 118 | + return data["token"] |
| 119 | + except Exception as exc: # callers degrade, they don't crash |
| 120 | + logger.warning("Could not mint a GitHub App installation token: %s", exc) |
| 121 | + return None |
| 122 | + |
| 123 | + |
| 124 | +def clone_url(repo: str, token: str | None = None) -> str: |
| 125 | + """An https clone URL for `repo` ("owner/name"), authenticated when a token is given.""" |
| 126 | + if token: |
| 127 | + return f"https://{GIT_USERNAME}:{token}@github.com/{repo}.git" |
| 128 | + return f"https://github.com/{repo}.git" |
0 commit comments