feat(auth): add GitHub App server-to-server authentication for stdio#2797
feat(auth): add GitHub App server-to-server authentication for stdio#2797SamMorrowDrums wants to merge 1 commit into
Conversation
Add non-interactive GitHub App installation authentication to the stdio server, so headless deployments (CI, Kubernetes, background agents) can authenticate without a browser, device code, or elicitation. This is the outstanding follow-up tracked in #1333: OAuth login shipped the interactive user-to-server flows, but PEM-based server-to-server auth was still needed to remove the interactive requirement. The new internal/githubapp package signs a short-lived RS256 JWT with the app's private key, exchanges it for an installation access token, and refreshes it transparently before expiry. It exposes a Provider whose AccessToken method mirrors oauth.Manager so it plugs into the existing BearerAuthTransport token provider. Only the standard library and golang.org/x/oauth2 are used. The private key is injected safely: a file path (GITHUB_APP_PRIVATE_KEY_PATH, preferred — mountable as a secret and kept off argv and out of the environment) or an inline GITHUB_APP_PRIVATE_KEY env var. There is intentionally no flag for the key contents, which would otherwise leak via the process command line. App auth is mutually exclusive with a PAT and with OAuth login. A loud startup warning and a dedicated docs page (docs/github-app-auth.md, with Docker and Kubernetes examples) cover the security considerations: this injects a high-privilege credential alongside the agent and is not recommended without an independent security review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds GitHub App server-to-server (installation token) authentication to the stdio GitHub MCP server, enabling fully headless deployments to authenticate without interactive OAuth/PAT provisioning.
Changes:
- Introduces
internal/githubappto mint RS256 app JWTs, exchange them for installation tokens, and refresh/cache tokens via anAccessToken()provider. - Wires stdio config to support an App-auth mode with explicit mutual exclusivity vs PAT and OAuth, plus a loud startup warning about credential risk.
- Adds documentation for GitHub App auth usage patterns (Docker/Kubernetes) and updates README / OAuth docs to point to it.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds a headless-deployment pointer to the new GitHub App auth docs and security warning. |
| internal/githubapp/githubapp.go | New GitHub App s2s auth implementation (JWT minting, installation-token fetch, caching/refresh provider). |
| internal/githubapp/githubapp_test.go | Unit tests for key parsing, JWT correctness, token fetching, caching/refresh, and error logging behavior. |
| internal/ghmcp/server.go | Adds AppAuth config, enforces auth-mode exclusivity, wires provider into token transport, and logs a startup warning. |
| internal/ghmcp/oauth_test.go | Extends exclusivity tests to cover PAT vs OAuth vs App auth combinations. |
| docs/oauth-login.md | Notes that OAuth is still human-mediated and points headless users to GitHub App auth. |
| docs/github-app-auth.md | New end-to-end documentation for stdio GitHub App auth, including security guidance and deployment examples. |
| cmd/github-mcp-server/main.go | Adds stdio flags/env handling for GitHub App auth, host→REST base resolution, and exclusivity checks. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 5
- Review effort level: Low
| // AppID is the GitHub App's App ID or client ID; it becomes the JWT issuer | ||
| // (iss). Both forms are accepted by GitHub. | ||
| AppID string |
| 3. Note three values: | ||
| - the **App ID** (or the app's **client ID** — either works as the JWT issuer), | ||
| - the **installation ID** (visible in the installation's settings URL, or via | ||
| the [installations API](https://docs.github.com/en/rest/apps/apps#list-installations-for-the-authenticated-app)), | ||
| - the path to the **private key** `.pem`. |
| | `--app-id` | `GITHUB_APP_ID` | GitHub App ID or client ID. Becomes the JWT issuer. | | ||
| | `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation ID whose token is minted. | | ||
| | `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM file. **Preferred** way to supply the key. | | ||
| | _(no flag)_ | `GITHUB_APP_PRIVATE_KEY` | The PEM contents inline. Use only where a file can't be mounted. Literal `\n` sequences are accepted so the key can live in a single-line variable. | |
| if token == "" && !appAuthRequested && oauthClientID == "" { | ||
| return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH), or pass --oauth-client-id to log in via OAuth") | ||
| } |
| // stdio-specific GitHub App (server-to-server) flags. Provide an app ID, | ||
| // installation ID, and private key to authenticate non-interactively — no | ||
| // browser, device code, or elicitation. Intended for headless deployments. | ||
| // The private key itself has no flag (only GITHUB_APP_PRIVATE_KEY): a flag | ||
| // would place the key in the process arguments. Prefer the key file path. | ||
| stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication") | ||
| stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for") | ||
| stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment") |
|
Thank you for the excellent proposal. Even with HTTP, it is possible to listen with a high degree of isolation in environments like Kubernetes. Therefore, instead of restricting it to stdio, I would like you to leave the decision up to the user. |
|
@pyama86 I understand it can be done safely, but I do have to be careful about what GitHub provides because users may use it in inappropriate contexts also, and the risk with http version is massively higher. With port binding to 0.0.0.0 a user could easily expose themselves to even website direct access. You are of course welcome to do whatever you like with a fork, while we iron out what's possible here. I will not even merge this until I discuss with security/auth folks in GitHub also. |
|
@SamMorrowDrums |
I was super happy to receive and merge that PR! Makes sense in this context especially. 🙏 |
Summary
Adds non-interactive GitHub App installation authentication to the stdio server, so headless deployments (CI, background agents, the Claude Agent SDK, a self-hosted image spawned by Copilot's cloud agent) can authenticate with no browser, device code, or elicitation.
This is the outstanding follow-up tracked in #1333: stdio OAuth (#2704) shipped the interactive user-to-server flows, but PEM-based server-to-server (s2s) auth was still needed to remove the human-in-the-loop requirement.
Builds on the prior art in #2562 (thanks @pyama86) — same goal, reworked for a tighter security posture and a stdlib-only implementation.
How it works
internal/githubappsigns a short-lived RS256 JWT with the app's private key, exchanges it for an installation access token, and refreshes it transparently ~5 minutes before expiry. The token is held in memory only. It exposes aProviderwhoseAccessToken()mirrorsoauth.Manager, so it slots into the existingBearerAuthTransporttoken provider with no new transport code. Only the standard library andgolang.org/x/oauth2are used (no new dependencies).Safe key injection
--app-id/GITHUB_APP_ID,--app-installation-id/GITHUB_APP_INSTALLATION_ID--app-private-key-path/GITHUB_APP_PRIVATE_KEY_PATH— preferred: mount as a secret file; keeps the key offargvand out of the environmentGITHUB_APP_PRIVATE_KEY— inline fallback for env-only secret storesThere is intentionally no flag for the key contents, which would otherwise leak via the process command line. App auth is mutually exclusive with a PAT and with OAuth login.
Why stdio only (by design)
This is not available for the
httpserver, and that's deliberate. An HTTP server authenticating with a server-wide app identity would let any client that can reach its endpoint act as the app with full permissions — turning a network-reachable port into ambient, unauthenticated access to the whole installation. Thehttpserver keeps requiring per-requestAuthorizationtokens so every caller's identity stays explicit. (Distinct from #2236, which is about per-request multi-identity tokens.)Security
Per popular demand, but dangerous: this injects a long-lived, high-privilege credential alongside an AI agent, and minted tokens act across every repo the app is installed on. A loud startup warning and a dedicated docs page (
docs/github-app-auth.md, with Docker + Kubernetes secret-mount examples, least-privilege guidance, and the stdio-only rationale) make this explicit. Not recommended without an independent security review.Test plan
internal/githubappunit tests: PKCS#1/fix: addedreasonargument toget_mefunction #8 key parsing, JWT round-trip + claim/lifetime verification, installation-token fetch (success / error status / expiry) againsthttptest, caching, early refresh, and error-once logging.internal/ghmcpmutual-exclusivity guard extended to cover all three modes (token / OAuth / App).script/lint(0 issues) andscript/test(race) green;generate-docsproduces no drift.Closes #1333
🤖 Generated with GitHub Copilot CLI
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com