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:
| Login | Command | Stored as | Used for |
|---|---|---|---|
| GitHub, source | make login-github (github-backup-worker login github) | github:source | Listing 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, destination | make login-github ARGS="--profile destination --from-gh" | github:destination | Pushing to an https:// BACKUP_REPO_PATH. SSH destinations do not need it. |
| OpenRouter | make login-openrouter (github-backup-worker login openrouter) | openrouter:default | An 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:
- A page on
http://127.0.0.1:<random port>/opens in the browser (the address is printed as well) and sends the manifest tohttps://github.com/settings/apps/new(or the organization's page) with a randomstate. - 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 (--namesets it beforehand). - GitHub redirects back to the local page with a one-time code. A redirect whose
statedoes 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 togithub-app.jsonin the configuration directory (mode 0600, directory 0700) before the browser is told that the app exists, because GitHub shows these secrets only once. An existinggithub-app.jsonis 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:
- Enable Device Flow. Open the settings URL the command printed (
https://github.com/settings/apps/<slug>, orhttps://github.com/organizations/<org>/settings/apps/<slug>), check Enable Device Flow under "Identifying and authorizing users", and save. - 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 tohttp://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-githubagain;auth statusshows the date. - Which repositories. With the default
GITHUB_DISCOVERY=auto, a GitHub App login listsGET /user/installationsand each installation's repositories (GET /user/installations/{id}/repositories) instead ofORG_ACCOUNT,PROJECT_ACCOUNTand 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 withGITHUB_DISCOVERY=accountsand tokens.GITHUB_DISCOVERY=accountslistsORG_ACCOUNTandPROJECT_ACCOUNTagain (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"whileghis logged in as that account); with an SSHBACKUP_REPO_PATHit is used only for this check. - Without an app.
make login-github ARGS=--from-ghimports 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, likeGITHUB_TOKEN_PRIVATEdid.
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).
| OS | Configuration 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,localhostor[::1]with their port, which blocks DNS rebinding. The manifest redirect needs the randomstate; the OpenRouter code needs the PKCE verifier. github-app.jsonholds the app's private key and client secret; keep it private. The worker needs only the client id.
Troubleshooting
| Message | Fix |
|---|---|
no GitHub App client id | Run make github-app once, or set GITHUB_APP_CLIENT_ID in the worker's .env, or use ARGS=--from-gh. |
device_flow_disabled | Enable Device Flow in the app's settings (step 1). |
incorrect_client_credentials during login | The 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 access | Install the app (step 1), or set GITHUB_DISCOVERY=accounts. |
read github:source from the OS keychain: ... in a scheduled run | The keychain was locked; log in again with ARGS="--store file". |
refresh the GitHub login ... bad_refresh_token or expired on | The authorization was revoked or the refresh token expired: make login-github. |
credentials.json is not valid JSON | Move the file away and log in again. |