Skip to content

Create GitHub App installation tokens from the app's private key - #545

Open
capehart wants to merge 1 commit into
josegonzalez:masterfrom
capehart:github-app-private-key
Open

capehart wants to merge 1 commit into
josegonzalez:masterfrom
capehart:github-app-private-key

Conversation

@capehart

@capehart capehart commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Follows up on #533: as discussed there, this is the stdlib-only version of the GitHub App support @durist started, opened separately as @Iamrodos suggested. Thanks to @durist for getting it going.

--as-app only accepts an installation token created elsewhere, and those expire after an hour, so the README currently steers long-running backups back to a PAT. With this, github-backup creates the installation token itself from the app's private key and replaces it before it expires.

github-backup myorg --organization --app-id 12345 --app-private-key app.private-key.pem -o ./backup --all

How it works

  • --app-id and --app-private-key (a path, or file://...) are required together. They imply --as-app and are rejected alongside -t, -f, --token-from-gh or the keychain options.
  • parse_args() checks that the key file exists and openssl is on PATH, so setup problems are reported with parser.error before any work starts.
  • build_app_jwt() builds the JWT (iat = now - 60, exp = now + 540, iss = --app-id, so a client ID also works) and signs it with openssl dgst -sha256 -sign.
  • The installation is looked up from the account being backed up (GET /users/{user}/installation, or /orgs/{org}/installation with --organization) once per run. --app-installation-id skips the lookup.
  • get_app_installation_token() POSTs to /app/installations/{id}/access_tokens through make_request_with_retry (GHES via get_github_api_host). It caches the token and its expires_at on args and creates a new one within 10 minutes of expiry.
  • get_auth() puts the current token into args.token_classic, so every existing --as-app code path (REST, GraphQL, downloads, git URLs) works unchanged.
  • retrieve_data() resolves auth per request instead of once per call, so a long paginated listing survives a refresh. Wiki clones rebuild their URL rather than reusing the one built before the repository clone. fetch_repository() already set-urls origin on every run, so existing clones get the fresh URL.

Trade-offs

  • openssl CLI vs. a library: it's only needed with --app-id, and nothing changes for anyone else. It's present on Linux and macOS, and Git for Windows ships it. The Docker image adds it. PyJWT plus cryptography would add the project's first runtime dependencies.
  • Security: the process now reads the App's long-lived private key, where a pre-minted token exposed only a credential that expires within the hour. Anyone who wants that separation can keep minting tokens elsewhere and pass them with -t, which works as before.
  • Per-request get_auth(): for keychain users this runs security once per page instead of once per retrieve_data() call. File-based tokens are read once and cached as before.
  • Not covered: make_request_with_retry() reuses a request's headers across its own retry sleeps. If a rate-limit sleep crosses token expiry, that one retry gets a 401. Installation tokens have higher rate limits, and fixing this would mean restructuring the retry helper, so I left it out.

Testing

  • pytest tests/: 210 passed on Python 3.10 and 3.14. flake8 with CI's flags, rst-lint README.rst and black on the changed lines are clean.
  • tests/test_app_auth.py (24 tests): flag validation and the early checks, a real RS256 signature verified with openssl, JWT claims, the installation lookup for users and orgs (once per run), the token request (github.com and GHES), caching, refresh near expiry, git URL format, error messages, and a paginated listing that switches tokens between pages.
  • Live: my nightly backup on a Synology NAS (amd64) runs from an image built by this repo's docker.yml on my fork, as an unprivileged user with --cap-drop ALL --read-only. It exits cleanly with every repository backed up. With this commit's image and no --app-installation-id, a one-repository run with APP_TOKEN_REFRESH_MARGIN forced to an hour looked up the installation once, created a new token five times during the run, and GitHub accepted every one. A missing key file stops with a one-line usage error.

🤖 Generated with Claude Code

--as-app only accepts an installation token created elsewhere. Those
expire after an hour, so long backups fail part way through. With
--app-id and --app-private-key, github-backup creates the token itself
and replaces it ten minutes before it expires. The installation is
looked up from the user or organization being backed up, unless
--app-installation-id names it.

The JWT is signed by the openssl command line tool so the project keeps
zero runtime dependencies; the Docker image now installs openssl. A
missing key file or openssl is reported by parse_args before any work
starts. API requests resolve auth per request, and wiki clones rebuild
their URL, so both pick up a refreshed token mid-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Iamrodos

Iamrodos commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Nice. Reviewed and tested exiting paths. Looks good to merge.

Could not test the GitHub App path myself because I don't have an app set up, so relying on the live test done by @capehart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants