Skip to content

Commit c904ea4

Browse files
authored
Merge pull request #545 from capehart/github-app-private-key
Create GitHub App installation tokens from the app's private key
2 parents ad8fea8 + 653924e commit c904ea4

4 files changed

Lines changed: 518 additions & 7 deletions

File tree

‎Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ RUN apk add --no-cache \
2424
ca-certificates \
2525
git \
2626
git-lfs \
27+
openssl \
2728
&& addgroup -g 1000 appuser \
2829
&& adduser -D -u 1000 -G appuser appuser
2930

‎README.rst‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ Show the CLI help output::
3737
CLI Help output::
3838

3939
github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [--token-from-gh]
40-
[-q] [--as-app] [-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i]
40+
[-q] [--as-app] [--app-id APP_ID]
41+
[--app-private-key APP_PRIVATE_KEY]
42+
[--app-installation-id APP_INSTALLATION_ID]
43+
[-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i]
4144
[--incremental-by-files]
4245
[--starred] [--all-starred] [--starred-skip-size-over MB]
4346
[--watched] [--followers] [--following] [--all]
@@ -78,6 +81,14 @@ CLI Help output::
7881
-q, --quiet supress log messages less severe than warning, e.g.
7982
info
8083
--as-app authenticate as github app instead of as a user.
84+
--app-id APP_ID github app id or client id; creates and refreshes
85+
installation tokens (implies --as-app)
86+
--app-private-key APP_PRIVATE_KEY
87+
path to the github app private key (.pem), optionally
88+
as file://...
89+
--app-installation-id APP_INSTALLATION_ID
90+
id of the github app installation to back up
91+
(default: looked up from the user or organization)
8192
-o, --output-directory OUTPUT_DIRECTORY
8293
directory at which to backup the repositories
8394
-l, --log-level LOG_LEVEL
@@ -212,9 +223,19 @@ For a user account (no ``--organization``), ``--as-app`` lists the repositories
212223
7. Go to *Install App* in your app's settings
213224
8. Select the account/organization and which repositories to back up
214225

215-
**CI/CD usage with GitHub Actions:**
226+
**Using the app's private key:**
216227

217-
Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
228+
Pass the App ID and private key file, and github-backup creates installation tokens itself, refreshing them before they expire so long-running backups keep working::
229+
230+
github-backup myorg --organization --app-id 12345 --app-private-key /path/to/app.private-key.pem -o ./backup --all
231+
232+
The app's installation on the user or organization being backed up is looked up automatically. To skip the lookup, pass ``--app-installation-id`` with the number at the end of the installation's settings URL (``.../settings/installations/<id>``).
233+
234+
``--app-id`` implies ``--as-app`` and cannot be combined with ``-t``, ``-f``, ``--token-from-gh`` or the keychain options. The ``iss`` claim of the signed JWT is set to the value of ``--app-id``, so the app's client ID works there too. The JWT is signed by the ``openssl`` command line tool, which must be on your ``PATH`` (the Docker image includes it).
235+
236+
**Using an existing installation token:**
237+
238+
If something else already creates the installation token, pass it with ``-t`` and ``--as-app``. In GitHub Actions, store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
218239

219240
- uses: actions/create-github-app-token@v1
220241
id: app-token
@@ -224,7 +245,7 @@ Store the App ID as a repository variable and the private key contents as a secr
224245

225246
- run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
226247

227-
Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
248+
Note: Installation tokens passed with ``-t`` expire after 1 hour and are not refreshed. For backups that may run longer, use ``--app-private-key`` instead.
228249

229250

230251
Prefer SSH

‎github_backup/github_backup.py‎

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import platform
1313
import random
1414
import re
15+
import shutil
1516
import socket
1617
import ssl
1718
import subprocess
@@ -45,6 +46,7 @@
4546
)
4647

4748
FILE_URI_PREFIX = "file://"
49+
APP_TOKEN_REFRESH_MARGIN = 10 * 60 # seconds
4850
logger = logging.getLogger(__name__)
4951

5052

@@ -195,6 +197,21 @@ def parse_args(args=None):
195197
dest="as_app",
196198
help="authenticate as github app instead of as a user.",
197199
)
200+
parser.add_argument(
201+
"--app-id",
202+
dest="app_id",
203+
help="github app id or client id; creates and refreshes installation tokens (implies --as-app)",
204+
)
205+
parser.add_argument(
206+
"--app-private-key",
207+
dest="app_private_key",
208+
help="path to the github app private key (.pem), optionally as file://...",
209+
)
210+
parser.add_argument(
211+
"--app-installation-id",
212+
dest="app_installation_id",
213+
help="id of the github app installation to back up (default: looked up from the user or organization)",
214+
)
198215
parser.add_argument(
199216
"-o",
200217
"--output-directory",
@@ -522,12 +539,39 @@ def parse_args(args=None):
522539
default=5,
523540
help="maximum number of retries for API calls (default: 5)",
524541
)
525-
return parser.parse_args(args)
542+
args = parser.parse_args(args)
543+
544+
if args.app_id or args.app_private_key or args.app_installation_id:
545+
if not (args.app_id and args.app_private_key):
546+
parser.error("--app-id and --app-private-key must be used together")
547+
if (
548+
args.token_classic
549+
or args.token_fine
550+
or args.token_from_gh
551+
or args.osx_keychain_item_name
552+
or args.osx_keychain_item_account
553+
):
554+
parser.error(
555+
"--app-id cannot be combined with -t, -f, --token-from-gh or keychain options"
556+
)
557+
args.app_private_key = args.app_private_key.removeprefix(FILE_URI_PREFIX)
558+
if not os.path.isfile(args.app_private_key):
559+
parser.error(
560+
"--app-private-key file not found: {0}".format(args.app_private_key)
561+
)
562+
if not shutil.which("openssl"):
563+
parser.error("--app-private-key requires the 'openssl' command on PATH")
564+
args.as_app = True
565+
566+
return args
526567

527568

528569
def get_auth(args, encode=True, for_git_cli=False):
529570
auth = None
530571

572+
if args.app_id:
573+
args.token_classic = get_app_installation_token(args)
574+
531575
if args.osx_keychain_item_name:
532576
if not args.osx_keychain_item_account:
533577
raise Exception(
@@ -676,6 +720,110 @@ def read_token_from_gh_cli(args):
676720
return token
677721

678722

723+
def build_app_jwt(private_key_path, claims):
724+
"""Build an RS256 JWT for authenticating as a GitHub App.
725+
726+
Signs with the openssl CLI so the project keeps no runtime dependencies.
727+
"""
728+
729+
def b64url(data):
730+
return base64.urlsafe_b64encode(data).rstrip(b"=")
731+
732+
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode("ascii"))
733+
signing_input = header + b"." + b64url(json.dumps(claims).encode("ascii"))
734+
735+
try:
736+
signature = subprocess.run(
737+
["openssl", "dgst", "-sha256", "-sign", private_key_path],
738+
input=signing_input,
739+
capture_output=True,
740+
check=True,
741+
).stdout
742+
except subprocess.CalledProcessError as e:
743+
stderr = e.stderr.decode("utf-8", errors="replace").strip()
744+
raise Exception("Unable to sign GitHub App JWT: {0}".format(stderr))
745+
746+
return (signing_input + b"." + b64url(signature)).decode("ascii")
747+
748+
749+
def get_app_installation_token(args):
750+
"""Return a GitHub App installation token, creating a new one when needed.
751+
752+
Installation tokens expire after an hour, so a new one is created once the
753+
cached token is within APP_TOKEN_REFRESH_MARGIN seconds of expiring.
754+
"""
755+
cached_token = getattr(args, "_app_token_value", None)
756+
if cached_token and time.time() < (
757+
args._app_token_expires_at - APP_TOKEN_REFRESH_MARGIN
758+
):
759+
return cached_token
760+
761+
# GitHub rejects an exp more than 10 minutes out; backdate iat to tolerate
762+
# clock drift.
763+
now = int(time.time())
764+
app_jwt = build_app_jwt(
765+
args.app_private_key, {"iat": now - 60, "exp": now + 540, "iss": args.app_id}
766+
)
767+
768+
if not args.app_installation_id:
769+
owner_type = "orgs" if args.organization else "users"
770+
installation = request_as_app(
771+
args,
772+
app_jwt,
773+
"/{0}/{1}/installation".format(owner_type, args.user),
774+
"Unable to find the GitHub App installation for {0}".format(args.user),
775+
)
776+
args.app_installation_id = str(installation["id"])
777+
logger.info(
778+
"Using GitHub App installation {0} for {1}".format(
779+
args.app_installation_id, args.user
780+
)
781+
)
782+
783+
response = request_as_app(
784+
args,
785+
app_jwt,
786+
"/app/installations/{0}/access_tokens".format(args.app_installation_id),
787+
"Unable to create GitHub App installation token",
788+
method="POST",
789+
)
790+
791+
args._app_token_value = response["token"]
792+
args._app_token_expires_at = calendar.timegm(
793+
time.strptime(response["expires_at"], "%Y-%m-%dT%H:%M:%SZ")
794+
)
795+
logger.info(
796+
"Created GitHub App installation token (expires {0})".format(
797+
response["expires_at"]
798+
)
799+
)
800+
return args._app_token_value
801+
802+
803+
def request_as_app(args, app_jwt, path, error_message, method="GET"):
804+
"""Call the GitHub API authenticated as the app itself, with its JWT."""
805+
request = Request(
806+
"https://{0}{1}".format(get_github_api_host(args), path),
807+
data=b"" if method == "POST" else None,
808+
method=method,
809+
)
810+
request.add_header("Accept", "application/vnd.github+json")
811+
request.add_header("Authorization", "Bearer " + app_jwt)
812+
813+
try:
814+
http_response = make_request_with_retry(request, app_jwt, args.max_retries)
815+
except HTTPError as exc:
816+
raise Exception(
817+
"{0}: HTTP {1} {2}: {3}".format(
818+
error_message,
819+
exc.code,
820+
exc.reason,
821+
exc.read().decode("utf-8", errors="replace"),
822+
)
823+
)
824+
return json.loads(http_response.read().decode("utf-8"))
825+
826+
679827
def get_github_repo_url(args, repository):
680828
if repository.get("is_gist"):
681829
if args.prefer_ssh:
@@ -734,7 +882,6 @@ def retrieve_data(args, template, query_args=None, paginated=True, lazy=False):
734882
Handles throttling, retries, read errors, and DMCA takedowns.
735883
"""
736884
query_args = query_args or {}
737-
auth = get_auth(args, encode=not args.as_app)
738885
per_page = 100
739886

740887
def _extract_next_page_url(link_header):
@@ -758,6 +905,8 @@ def _extract_legal_url(response_body_bytes):
758905
# FIRST: Fetch response
759906

760907
for attempt in range(args.max_retries + 1):
908+
# Resolved per request so long runs pick up refreshed app tokens
909+
auth = get_auth(args, encode=not args.as_app)
761910
request = _construct_request(
762911
per_page=per_page if paginated else None,
763912
query_args=query_args,
@@ -2210,7 +2359,8 @@ def backup_repositories(args, output_directory, repositories):
22102359
if repository["has_wiki"] and download_wiki:
22112360
fetch_repository(
22122361
repository["name"],
2213-
repo_url.replace(".git", ".wiki.git"),
2362+
# Rebuilt so a long repository clone can't leave it an expired app token
2363+
get_github_repo_url(args, repository).replace(".git", ".wiki.git"),
22142364
os.path.join(repo_cwd, "wiki"),
22152365
skip_existing=args.skip_existing,
22162366
bare_clone=args.bare_clone,

0 commit comments

Comments
 (0)