Skip to content

feat(auth): add GitHub App server-to-server authentication for stdio#2797

Open
SamMorrowDrums wants to merge 4 commits into
mainfrom
sammorrowdrums-github-app-s2s-auth
Open

feat(auth): add GitHub App server-to-server authentication for stdio#2797
SamMorrowDrums wants to merge 4 commits into
mainfrom
sammorrowdrums-github-app-s2s-auth

Conversation

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator

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/githubapp signs 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 a Provider whose AccessToken() mirrors oauth.Manager, so it slots into the existing BearerAuthTransport token provider with no new transport code. Only the standard library and golang.org/x/oauth2 are 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_PATHpreferred: mount as a secret file; keeps the key off argv and out of the environment
  • GITHUB_APP_PRIVATE_KEY — inline fallback for env-only secret stores

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.

Why stdio only (by design)

This is not available for the http server, 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. The http server keeps requiring per-request Authorization tokens 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/githubapp unit tests: PKCS#1/fix: added reason argument to get_me function #8 key parsing, JWT round-trip + claim/lifetime verification, installation-token fetch (success / error status / expiry) against httptest, caching, early refresh, and error-once logging.
  • internal/ghmcp mutual-exclusivity guard extended to cover all three modes (token / OAuth / App).
  • script/lint (0 issues) and script/test (race) green; generate-docs produces no drift.

Closes #1333


🤖 Generated with GitHub Copilot CLI

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

@SamMorrowDrums SamMorrowDrums requested a review from a team as a code owner June 29, 2026 22:06
Copilot AI review requested due to automatic review settings June 29, 2026 22:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/githubapp to mint RS256 app JWTs, exchange them for installation tokens, and refresh/cache tokens via an AccessToken() 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

Comment on lines +67 to +69
// 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't correct. GitHub's own docs state the iss claim can be either the client ID or the App ID, and actually recommend the client ID:

iss (Issuer): The client ID or application ID of your GitHub App... Use of the client ID is recommended.

Source: Generating a JWT for a GitHub App — the Ruby example there even uses iss: "YOUR_CLIENT_ID". Accepting both is intentional, so no change here.

Comment thread docs/github-app-auth.md
Comment on lines +95 to +99
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`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the code thread: GitHub's JWT docs say the iss claim is "the client ID or application ID of your GitHub App" and that "use of the client ID is recommended." The prerequisites section is accurate as written.

Comment thread docs/github-app-auth.md
Comment on lines +109 to +112
| `--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. |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config table is correct. Per GitHub's JWT docs, the JWT issuer may be the client ID or the App ID, and the client ID is the recommended value. --app-id / GITHUB_APP_ID accepting either is intended.

Comment on lines +68 to +70
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")
}
Comment on lines +266 to +273
// 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")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text is intentional. GitHub's JWT docs accept either the client ID or the App ID as the iss, and recommend the client ID — so "App ID or client ID" is the right guidance.

@pyama86

pyama86 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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.

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator Author

@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.

@go-kazuhiko-yamashita

Copy link
Copy Markdown

@SamMorrowDrums
I also agree that we should be cautious about GitHub's responsibility as a platform and the baseline it needs to maintain.
By the way, the other PR of mine that got merged #2655 was intended to make it listen on 127.0.0.1. I completely respect your concerns, so I'm happy to wait for the outcome.

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator Author

@SamMorrowDrums

I also agree that we should be cautious about GitHub's responsibility as a platform and the baseline it needs to maintain.

By the way, the other PR of mine that got merged #2655 was intended to make it listen on 127.0.0.1. I completely respect your concerns, so I'm happy to wait for the outcome.

I was super happy to receive and merge that PR! Makes sense in this context especially. 🙏

SamMorrowDrums and others added 2 commits July 13, 2026 23:14
…und-trip elicitation

The go-sdk 1.7.0-pre.2 bump defaults to MCP protocol 2026-07-28, which per
SEP-2322 forbids the server from initiating JSON-RPC requests (including
`elicitation/create`) while serving a request. The OAuth login flow presents
the authorization prompt via `ServerSession.Elicit`, so on 2026-07-28 sessions
it now errors ("cannot be sent while serving a request ... return an
InputRequests map instead"), which broke TestSessionPrompterPromptActions and
would break real 2026-07-28 clients (stdio included, since server/discover is
transport-agnostic).

Migrate the OAuth middleware to multi-round-trip requests (MRTR) while keeping
pre-2026-07-28 clients unchanged:

- Legacy clients (< 2026-07-28) keep presenting the prompt via server-initiated
  elicitation exactly as before.
- Modern clients (>= 2026-07-28) receive the authorization prompt as an
  `InputRequests` elicitation returned from the tool call; the client fulfills
  it and retries, and the middleware then awaits the token and proceeds. This
  keeps the authorization URL out of the model context.

oauth.Manager gains AwaitToken (resume half of MRTR) and Cancel (tear down on
decline). Tests cover the accept/decline/no-capability MRTR paths and assert
that server-initiated elicitation is reported undeliverable on 2026-07-28.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18e70efa-1b2d-4290-ba51-b82998db4ff8
Move OAuth interception into tool-handler middleware so go-sdk finalizes
multi-round-trip results with resultType input_required. Correlate responses to
a per-flow ID, retire cancellations synchronously, and ignore late completions
from stale flows.

Also preserve actionable URLs for form-only clients and add wire-level,
concurrency, and real manager lifecycle coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18e70efa-1b2d-4290-ba51-b82998db4ff8
SamMorrowDrums and others added 2 commits July 15, 2026 15:15
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>
@SamMorrowDrums SamMorrowDrums force-pushed the sammorrowdrums-github-app-s2s-auth branch from f2bcfcf to 021c071 Compare July 15, 2026 13:29
@SamMorrowDrums SamMorrowDrums changed the base branch from main to sammorrowdrums-go-sdk-upgrade-fix July 15, 2026 13:29
Base automatically changed from sammorrowdrums-go-sdk-upgrade-fix to main July 15, 2026 13:35
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.

Support GitHub App authentication with client credentials

5 participants