Skip to main content

Backup Worker Logins: GitHub App, Device Flow and OpenRouter

The backup worker (src/services/github-backup-worker) can log in instead of reading personal access tokens from .env:

LoginCommandStored asUsed for
GitHub, sourcemake login-github (github-backup-worker login github)github:sourceListing and cloning the repositories to back up. With a GitHub App login, the repositories are exactly those granted to the app's installations, private ones included.
GitHub, destinationmake login-github ARGS="--profile destination --from-gh"github:destinationPushing to an https:// BACKUP_REPO_PATH. SSH destinations do not need it.
OpenRoutermake login-openrouter (github-backup-worker login openrouter)openrouter:defaultAn OpenRouter API key for scripts (github-backup-worker auth token openrouter); the worker itself does not call OpenRouter.

Without any stored login the worker keeps using GITHUB_TOKEN_PERSONAL and GITHUB_TOKEN_PRIVATE exactly as before. Once github:source is stored, those variables are no longer used.


1. Create the GitHub App (once per owner)​

make github-app # the app belongs to your user account
make github-app ORG=my-org # or to an organization you administer

This runs github-backup-worker github-app create, which registers the app described by src/services/github-backup-worker/githubapp/manifest.json through GitHub's app manifest flow:

  1. A page on http://127.0.0.1:<random port>/ opens in the browser (the address is printed as well) and sends the manifest to https://github.com/settings/apps/new (or the organization's page) with a random state.
  2. GitHub asks you to confirm. The name is prefilled as github-backup-<6 hex digits> because app names are unique on GitHub; change it there if you like (--name sets it beforehand).
  3. GitHub redirects back to the local page with a one-time code. A redirect whose state does not match is refused. The code is exchanged (POST /app-manifests/{code}/conversions) for the app's id, slug, client id, client secret, private key and webhook secret, which are saved to github-app.json in the configuration directory (mode 0600, directory 0700) before the browser is told that the app exists, because GitHub shows these secrets only once. An existing github-app.json is only replaced with --force.

The app is read-only: repository permissions Contents: read and Metadata: read, no webhook (inactive, no events), public (so each of your accounts can install it), and "request user authorization during installation" is on.

Then do what a manifest cannot:

  1. Enable Device Flow. Open the settings URL the command printed (https://github.com/settings/apps/<slug>, or https://github.com/organizations/<org>/settings/apps/<slug>), check Enable Device Flow under "Identifying and authorizing users", and save.
  2. Install the app on every account whose repositories you want backed up: https://github.com/apps/<slug>/installations/new. Choose "All repositories" or select them. Because the app requests user authorization on install, GitHub may then send the browser to http://127.0.0.1/callback, which does not load; close that tab.

The worker never uses the app's private key. The client secret is only sent if GitHub refuses a token refresh without it.

On other machines you do not need github-app.json: put the public client id into the worker's .env (GITHUB_APP_CLIENT_ID=Iv23..., shown by github-backup-worker auth status on the first machine), or pass --client-id.

2. Log in to GitHub (each machine)​

make login-github

The command prints https://github.com/login/device and a code such as ABCD-1234, opens the browser when there is a display, and waits while you enter the code and authorize the app (the code is valid for 15 minutes). It then records the account (GET /user), stores the login and lists the app's installations, so a missing installation shows up immediately.

  • Lifetime. A GitHub App user token lasts 8 hours and is refreshed automatically before it expires; each refresh rotates the refresh token, which lasts about six months. A machine that does not run a backup for six months needs make login-github again; auth status shows the date.
  • Which repositories. With the default GITHUB_DISCOVERY=auto, a GitHub App login lists GET /user/installations and each installation's repositories (GET /user/installations/{id}/repositories) instead of ORG_ACCOUNT, PROJECT_ACCOUNT and the private listing. GitHub lists only what the logged-in account itself may access: installations on its own account and on organizations it belongs to, and within each installation the repositories it can read. The repositories of another user account are therefore visible only where this account is a collaborator; back those up with that account's own login on a machine, or with GITHUB_DISCOVERY=accounts and tokens. GITHUB_DISCOVERY=accounts lists ORG_ACCOUNT and PROJECT_ACCOUNT again (with the login's token, plus the private repositories it can see). Finding no installation fails the run instead of treating every repository as deleted.
  • Private destination check. Private repositories only go into a git destination that the GitHub API confirms is private, so some login must be able to read the destination repository. The worker asks with the destination login first, then the source login, then anonymously. When the destination belongs to another account (for example a separate backup account reached through an SSH alias), store that account's token as the destination login (make login-github ARGS="--profile destination --from-gh" while gh is logged in as that account); with an SSH BACKUP_REPO_PATH it is used only for this check.
  • Without an app. make login-github ARGS=--from-gh imports the GitHub CLI's token (gh auth token) as a non-expiring login. In accounts discovery it then also lists every private repository that account can see, like GITHUB_TOKEN_PRIVATE did.

Where the login is stored​

The secret goes to the OS keychain (service github-backup, account github:source): the macOS Keychain, the Secret Service (GNOME Keyring, KWallet) on Linux, the Windows Credential Manager. credentials.json in the configuration directory indexes each login (kind, account, expiry, where the secret is) without keychain secrets, so auth status never touches the keychain. When no keychain is available (a headless Linux machine, a container), or with --store file, the secret is kept in credentials.json itself (mode 0600, directory 0700).

OSConfiguration directory
Linux$XDG_CONFIG_HOME/github-backup, usually ~/.config/github-backup
macOS~/Library/Application Support/github-backup
Windows%AppData%\github-backup

Scheduled runs and the keychain. A scheduled run reads the login from the keychain, which must be unlocked at that moment. On Linux with lingering enabled (so the timer also runs while you are logged out, for example the catch-up run right after a boot), the login keyring is still locked and the run fails with a keychain error. On such machines log in with make login-github ARGS="--store file". The worker never falls back to the environment tokens when a stored login cannot be read, because that would list fewer repositories and mark the others missing.

3. HTTPS destination (optional)​

When BACKUP_REPO_PATH is an https:// URL, the git sink pushes with the github:destination login, sent as an HTTP header scoped to that URL through GIT_CONFIG_*. The token is never in a command line, the remote URL or the clone's .git/config. Plain http:// is refused except to a loopback address.

The destination token needs write access to the destination repository. The backup app is read-only, so use the GitHub CLI's token of the account that owns the destination:

gh auth login # as the destination's owner, if needed
make login-github ARGS="--profile destination --from-gh --store file"

SSH destinations (including host aliases such as git@github.com-learning:owner/backups.git) keep using your SSH configuration and ignore this login.

4. OpenRouter (optional)​

make login-openrouter # a local callback on http://localhost:<port>/callback
make login-openrouter ARGS=--no-browser # print the address, paste the code OpenRouter shows
~/.local/bin/github-backup-worker auth token openrouter # prints only the key

The login uses OpenRouter's PKCE flow (S256 challenge; the code only works with this login's verifier) and stores the new key as openrouter:default. The observatory still reads OPENROUTER_API_KEY from its own .env (possibly several comma-separated keys); to use the stored key there, export it yourself, for example OPENROUTER_API_KEY="$(~/.local/bin/github-backup-worker auth token openrouter)" make dev. make dev does not do this on its own, because a variable in the environment would replace the key list in the observatory's .env.

5. Checking and removing logins​

github-backup-worker auth status # logins, environment tokens, GitHub App, discovery mode; never a secret
github-backup-worker auth status --check # exit 0 when a GitHub login or token is configured
github-backup-worker logout github [--profile destination]
github-backup-worker logout openrouter
github-backup-worker logout all # every login and github-app.json
make uninstall ARGS=--purge-credentials # the schedule, the binary, every login and github-app.json

Removing a login does not revoke the token on GitHub: revoke GitHub App authorizations at https://github.com/settings/apps/authorizations and the GitHub CLI's token with gh auth logout. The GitHub App itself stays registered until you delete it in its settings.

Security notes​

  • Every login secret stays in the keychain or a 0600 file; auth status, logs and errors never print it, and git errors are redacted.
  • The local pages of the browser logins listen on the loopback interface only and answer only requests addressed to 127.0.0.1, localhost or [::1] with their port, which blocks DNS rebinding. The manifest redirect needs the random state; the OpenRouter code needs the PKCE verifier.
  • github-app.json holds the app's private key and client secret; keep it private. The worker needs only the client id.

Troubleshooting​

MessageFix
no GitHub App client idRun make github-app once, or set GITHUB_APP_CLIENT_ID in the worker's .env, or use ARGS=--from-gh.
device_flow_disabledEnable Device Flow in the app's settings (step 1).
incorrect_client_credentials during loginThe client id is wrong (check GITHUB_APP_CLIENT_ID and github-app.json).
the GitHub App is not installed on any account this login can accessInstall the app (step 1), or set GITHUB_DISCOVERY=accounts.
read github:source from the OS keychain: ... in a scheduled runThe keychain was locked; log in again with ARGS="--store file".
refresh the GitHub login ... bad_refresh_token or expired onThe authorization was revoked or the refresh token expired: make login-github.
credentials.json is not valid JSONMove the file away and log in again.