diff --git a/README.md b/README.md index 973b926d66..d556374d49 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ Add one of the following JSON blocks to your IDE's MCP settings. See **[Local Server OAuth Login](docs/oauth-login.md)** for the native-binary flow (no fixed port needed), the headless/device-code fallback, GitHub Enterprise Server / `ghe.com`, and bringing your own OAuth or GitHub App. +**Running headless (CI, Kubernetes, background agents)?** The stdio server can authenticate as a **GitHub App installation** with no browser, device code, or elicitation — see **[GitHub App Server-to-Server Authentication](docs/github-app-auth.md)**. This injects a high-privilege credential alongside the agent, so read the security guidance there first; it is not recommended without an independent security review. + **Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth): ```json diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 231b0cf2c3..7629889293 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "os" @@ -9,10 +10,12 @@ import ( "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" ghhttp "github.com/github/github-mcp-server/pkg/http" ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -37,6 +40,17 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") + + // GitHub App server-to-server auth (non-interactive). It is detected + // when any app-* setting is present; a partial configuration yields a + // clear error from the loader/validator below rather than silently + // falling back to another mode. + appID := viper.GetString("app-id") + appInstallationID := viper.GetString("app-installation-id") + appPrivateKeyPath := viper.GetString("app-private-key-path") + appPrivateKeyInline := viper.GetString("app-private-key") + appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != "" + oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") // Fall back to the build-time baked-in client (official releases) when none is @@ -45,13 +59,20 @@ var ( // --oauth-client-id. Recognizing the host via NormalizeHost means an explicit // GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps // zero-config login working. The secret tracks the id, so an explicitly provided - // id with no secret never picks up the baked-in secret. - if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { + // id with no secret never picks up the baked-in secret. App auth opts out of this + // default so configuring an app never accidentally enables OAuth login too. + if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } - if token == "" && oauthClientID == "" { - return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") + 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") + } + if appAuthRequested && token != "" { + return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one") + } + if appAuthRequested && oauthClientID != "" { + return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -116,7 +137,8 @@ var ( // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. - if token == "" { + // Skipped for GitHub App auth, which sources tokens non-interactively. + if token == "" && !appAuthRequested { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { @@ -134,6 +156,17 @@ var ( stdioServerConfig.OAuthScopes = scopes } + // GitHub App server-to-server auth: load and parse the private key, + // then resolve the REST base URL so the server can mint installation + // tokens for the configured host (github.com, GHES, or ghe.com). + if appAuthRequested { + appConfig, err := buildAppAuthConfig(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host")) + if err != nil { + return err + } + stdioServerConfig.AppAuth = appConfig + } + return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -230,6 +263,15 @@ func init() { stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") + // 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") + // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") @@ -256,6 +298,9 @@ func init() { _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) + _ = viper.BindPFlag("app-id", stdioCmd.Flags().Lookup("app-id")) + _ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id")) + _ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) @@ -281,6 +326,60 @@ func main() { } } +// buildAppAuthConfig assembles the GitHub App server-to-server configuration: +// it loads and parses the private key and resolves the REST base URL for the +// configured host. The private key is read from a file (preferred) or an inline +// environment value; a missing or partial configuration yields a clear error. +func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string) (*githubapp.Config, error) { + keyBytes, err := loadAppPrivateKey(keyPath, keyInline) + if err != nil { + return nil, err + } + privateKey, err := githubapp.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid GitHub App private key: %w", err) + } + + apiHost, err := utils.NewAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err) + } + restURL, err := apiHost.BaseRESTURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err) + } + + cfg := &githubapp.Config{ + AppID: appID, + InstallationID: installationID, + PrivateKey: privateKey, + BaseRESTURL: restURL.String(), + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// loadAppPrivateKey returns the GitHub App private key bytes from a file path +// (preferred — it keeps the key off argv and out of the environment) or from an +// inline value. The inline form tolerates literal "\n" escapes so a PEM survives +// being carried in a single-line environment variable. +func loadAppPrivateKey(path, inline string) ([]byte, error) { + switch { + case path != "": + data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key + if err != nil { + return nil, fmt.Errorf("reading GitHub App private key file: %w", err) + } + return data, nil + case inline != "": + return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil + default: + return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY") + } +} + func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { from := []string{"_"} to := "-" diff --git a/docs/github-app-auth.md b/docs/github-app-auth.md new file mode 100644 index 0000000000..ed9a3db11f --- /dev/null +++ b/docs/github-app-auth.md @@ -0,0 +1,261 @@ +# GitHub App Server-to-Server Authentication (stdio) + +The local (stdio) GitHub MCP Server can authenticate as a **GitHub App +installation** instead of as a user. This is a **server-to-server** (s2s) flow: +the server signs a short-lived JSON Web Token (JWT) with your app's private key, +exchanges it for an installation access token, and refreshes that token +automatically. There is **no browser, no device code, and no elicitation**, so +it works in fully non-interactive environments — CI, Kubernetes, and background +agents such as Copilot's cloud agent. + +> [!WARNING] +> **Read this before you enable it.** This mode was added by popular demand, but +> it is **dangerous** and is **not recommended without an independent security +> review** of your deployment and of this implementation. +> +> - It places a **long-lived, high-privilege credential** (your app's private +> key) in the same environment as an AI agent. Anyone or anything that can read +> that environment can mint tokens that act as your app. +> - Installation access tokens minted here can act across **every repository the +> app is installed on**, with the app's full set of permissions. +> - Exposing credentials to agents — and **especially in the cloud** — is +> inherently risky. Treat this as a break-glass capability and proceed with +> **extreme caution**. +> +> If an interactive login is at all possible for your use case, prefer +> [OAuth login](oauth-login.md) instead, which keeps no long-lived secret next to +> the agent. + +## Contents + +- [When to use this](#when-to-use-this) +- [Why stdio only](#why-stdio-only) +- [How it works](#how-it-works) +- [Prerequisites](#prerequisites) +- [Configuration reference](#configuration-reference) +- [Injecting the private key safely](#injecting-the-private-key-safely) +- [Quick start](#quick-start) +- [Kubernetes](#kubernetes) +- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) +- [Reducing the blast radius](#reducing-the-blast-radius) +- [Troubleshooting](#troubleshooting) + +## When to use this + +Use GitHub App s2s auth only when **all** of the following hold: + +- The server runs **non-interactively** (no human to complete a browser or + device flow). +- The workload should act as an **organization-managed identity** (the app), + not a single user's Personal Access Token (PAT). +- You have reviewed the security implications above and accept them. + +For everything else, prefer [OAuth login](oauth-login.md) or a +[PAT](https://github.com/settings/personal-access-tokens/new). + +## Why stdio only + +This mode is deliberately limited to the **stdio** server, where the server runs +as a subprocess of a single trusted client and the minted token never crosses +that process boundary. + +It is intentionally **not** available for the `http` server. An HTTP server that +authenticated with a server-wide app identity would let **any** client that can +reach its endpoint act as the app, with the app's full permissions — turning a +network-reachable port into ambient, unauthenticated access to your whole +installation. The `http` server therefore keeps requiring a per-request +`Authorization` token, so every caller's identity and permissions stay explicit. + +If you need a hosted, networked deployment, authenticate callers at the +client/proxy layer and pass per-request tokens; don't give the server a standing +identity. + +## How it works + +1. The server builds a JWT and signs it with your app's private key (RS256). The + JWT is valid for under 10 minutes (GitHub's maximum) and identifies your app. +2. It calls `POST /app/installations/{installation_id}/access_tokens` with that + JWT to obtain an **installation access token** (prefixed `ghs_`), which is + valid for up to one hour. +3. Every GitHub API call uses that token. The server refreshes it about five + minutes before it expires, so long-running sessions keep working without any + intervention. + +The private key is held **in memory only**; the server never writes it or the +minted tokens to disk. + +## Prerequisites + +1. **Register a GitHub App** and generate a **private key** (Settings → your + app → *Private keys* → *Generate a private key*). GitHub downloads a `.pem` + file in PKCS#1 or PKCS#8 format — both are accepted. +2. **Install the app** on the account/organization and grant it the **minimum** + permissions and **only the repositories** it needs (see + [Reducing the blast radius](#reducing-the-blast-radius)). +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`. + +## Configuration reference + +App auth is enabled when **any** of these `app-*` settings is present; a +partial configuration produces a clear startup error. Settings apply only to the +`stdio` command. + +| Flag | Environment variable | Description | +|------|----------------------|-------------| +| `--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. | + +There is intentionally **no flag** for the private key contents: a flag would +place the key in the process's command line (`ps`, `/proc//cmdline`), where +other processes could read it. + +App auth is **mutually exclusive** with a PAT (`GITHUB_PERSONAL_ACCESS_TOKEN`) +and with OAuth login (`--oauth-client-id`). Configure exactly one. + +## Injecting the private key safely + +The private key is the most sensitive value in this flow. In order of +preference: + +1. **A mounted secret file** (recommended). Point `GITHUB_APP_PRIVATE_KEY_PATH` + at a file your platform mounts from its secret store — a Kubernetes secret + volume, a Docker secret, or a tmpfs file written by your secret manager. The + key never touches the command line or the process environment. +2. **An inline environment variable** (`GITHUB_APP_PRIVATE_KEY`). Acceptable + where files can't be mounted, but the key is then readable by anything that + can inspect the process environment. Avoid this in shared or cloud + environments. + +Never pass the key on the command line, never bake it into an image, and never +commit it to source control. + +## Quick start + +Native binary, key on disk: + +```bash +github-mcp-server stdio \ + --app-id 123456 \ + --app-installation-id 7891011 \ + --app-private-key-path /secrets/github-app.pem +``` + +Equivalently, with environment variables: + +```bash +export GITHUB_APP_ID=123456 +export GITHUB_APP_INSTALLATION_ID=7891011 +export GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem +github-mcp-server stdio +``` + +Docker, mounting the key as a read-only file (preferred over passing it inline): + +```bash +docker run -i --rm \ + -v /secrets/github-app.pem:/secrets/github-app.pem:ro \ + -e GITHUB_APP_ID=123456 \ + -e GITHUB_APP_INSTALLATION_ID=7891011 \ + -e GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem \ + ghcr.io/github/github-mcp-server +``` + +## Kubernetes + +Store the key in a `Secret` and mount it as a file; pass the IDs as environment +variables. This keeps the key off the command line and out of the container's +environment. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: github-app +type: Opaque +stringData: + private-key.pem: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- +--- +apiVersion: v1 +kind: Pod +metadata: + name: github-mcp-server +spec: + containers: + - name: github-mcp-server + image: ghcr.io/github/github-mcp-server + stdin: true + env: + - name: GITHUB_APP_ID + value: "123456" + - name: GITHUB_APP_INSTALLATION_ID + value: "7891011" + - name: GITHUB_APP_PRIVATE_KEY_PATH + value: /secrets/github-app/private-key.pem + volumeMounts: + - name: github-app + mountPath: /secrets/github-app + readOnly: true + volumes: + - name: github-app + secret: + secretName: github-app +``` + +## GitHub Enterprise Server and ghe.com + +Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the correct +installation token endpoint from it, so tokens are minted against your instance +rather than github.com. Register the app and generate its key on that same host. + +```bash +github-mcp-server stdio \ + --gh-host https://github.example.com \ + --app-id 123456 \ + --app-installation-id 7891011 \ + --app-private-key-path /secrets/github-app.pem +``` + +- For GitHub Enterprise Server, prefix the host with `https://`. +- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`. + +## Reducing the blast radius + +Because the minted token can act across the whole installation, minimize what it +can do: + +- **Grant least privilege.** Enable only the app permissions the workload needs, + and prefer read-only where possible. +- **Scope the installation to specific repositories** rather than *All + repositories*. +- **Rotate the private key** periodically and immediately if it may have been + exposed (Settings → your app → *Private keys*). +- **Isolate the runtime.** Run the server where only trusted code shares its + process environment and mounted secrets. +- **Combine with `--read-only` and toolset/scoping flags** to further narrow + what the agent can invoke. See the + [Server Configuration Guide](server-configuration.md). + +## Troubleshooting + +- **`GitHub App authentication requires a private key`** — you set some `app-*` + values but no key. Set `GITHUB_APP_PRIVATE_KEY_PATH` (preferred) or + `GITHUB_APP_PRIVATE_KEY`. +- **`invalid GitHub App private key`** — the PEM could not be parsed. Ensure it + is the app's RSA private key in PKCS#1 or PKCS#8 form and was not truncated + (when inline, encode newlines as literal `\n`). +- **`installation token request failed: 401`** — usually a clock-skew problem or + the wrong App ID/key pairing. Check the host clock and that the key belongs to + the configured app. +- **`installation token request failed: 404`** — the installation ID is wrong, + or the app is not installed where you think. Re-check the installation ID. +- **`... and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive`** — a PAT is + also set in the environment. Unset it; choose exactly one auth mode. diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 16c5dab67e..31a0c90dce 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -15,6 +15,13 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). > `http` command have their own authentication; see > [Remote Server](remote-server.md). +> **Running non-interactively?** OAuth still needs a human to complete the flow +> once. For fully headless deployments (CI, Kubernetes, background agents), +> authenticate as a GitHub App installation instead — see +> [GitHub App Server-to-Server Authentication](github-app-auth.md). Note the +> security warnings there: it keeps a high-privilege credential next to the +> agent and is not recommended without an independent security review. + ## Contents - [How it works](#how-it-works) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 732d080e40..2a457881f4 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "testing" + "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/headers" @@ -332,20 +333,40 @@ func TestCreateOAuthMiddleware(t *testing.T) { }) } -// TestRunStdioServerRejectsTokenAndOAuth verifies the mutually-exclusive guard: -// supplying both a static token and an OAuth manager is rejected before the -// server starts, rather than silently preferring one for auth and the other for -// scope filtering. -func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) { +// TestRunStdioServerRejectsMultipleAuthModes verifies the mutually-exclusive +// guard: supplying more than one of a static token, an OAuth manager, or GitHub +// App auth is rejected before the server starts, rather than silently preferring +// one for auth and another for scope filtering. +func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) { t.Parallel() mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) - err := RunStdioServer(StdioServerConfig{ - Token: "ghp_static", - OAuthManager: mgr, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "mutually exclusive") + + tests := []struct { + name string + cfg StdioServerConfig + }{ + { + name: "token and oauth", + cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr}, + }, + { + name: "token and app", + cfg: StdioServerConfig{Token: "ghp_static", AppAuth: &githubapp.Config{}}, + }, + { + name: "oauth and app", + cfg: StdioServerConfig{OAuthManager: mgr, AppAuth: &githubapp.Config{}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := RunStdioServer(tt.cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one authentication mode") + }) + } } // TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1bf84453c8..036859faf0 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" @@ -257,15 +258,31 @@ type StdioServerConfig struct { // are hidden. The default set is the full supported list, which hides // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string + + // AppAuth, when non-nil, enables non-interactive GitHub App server-to-server + // authentication: the server mints and transparently refreshes installation + // access tokens from the app's private key, with no browser, device code, or + // elicitation. It suits headless deployments (CI, Kubernetes, background + // agents). It is mutually exclusive with a static Token and with + // OAuthManager. See internal/githubapp and docs/github-app-auth.md — this + // injects a high-privilege credential alongside the agent and should not be + // used without an independent security review. + AppAuth *githubapp.Config } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { - // OAuth login and a static token are mutually exclusive: they would - // disagree on how the token is sourced (lazy provider vs. static) and on - // scope filtering, so reject the ambiguous combination up front. - if cfg.OAuthManager != nil && cfg.Token != "" { - return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") + // A static token, OAuth login, and GitHub App auth are mutually exclusive: + // they disagree on how the token is sourced (static vs. lazy provider) and + // on scope filtering, so reject any ambiguous combination up front. + authModes := 0 + for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.AppAuth != nil} { + if on { + authModes++ + } + } + if authModes > 1 { + return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager (OAuth login), or AppAuth (GitHub App)") } // Create app context @@ -290,6 +307,20 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + // GitHub App server-to-server auth mints installation tokens with no human + // in the loop. Build the provider here so it can use the configured logger. + var appProvider *githubapp.Provider + if cfg.AppAuth != nil { + // Surfaced loudly because this injects a high-privilege credential next + // to the agent; the detailed guidance lives in docs/github-app-auth.md. + logger.Warn("GitHub App server-to-server authentication is enabled; installation tokens minted here can act across every repository the app is installed on — review docs/github-app-auth.md and prefer least-privilege, repository-scoped installations") + provider, err := githubapp.NewProvider(*cfg.AppAuth, logger) + if err != nil { + return fmt.Errorf("failed to configure GitHub App authentication: %w", err) + } + appProvider = provider + } + // Determine the scope set used to filter tools. Classic PATs expose their // granted scopes via the API; OAuth uses the requested scopes (the default // set hides nothing, a narrower explicit set filters accordingly). Other @@ -311,11 +342,15 @@ func RunStdioServer(cfg StdioServerConfig) error { logger.Debug("skipping scope filtering for non-PAT token") } - // For OAuth, the token is resolved lazily: empty until the user authorizes - // on the first tool call, then refreshed for the rest of the session. + // For OAuth or GitHub App auth, the token is resolved lazily by a provider: + // empty until the user authorizes (OAuth) or minted on demand and refreshed + // (App). A static PAT, by contrast, is passed through unchanged. var tokenProvider func() string - if cfg.OAuthManager != nil { + switch { + case cfg.OAuthManager != nil: tokenProvider = cfg.OAuthManager.AccessToken + case appProvider != nil: + tokenProvider = appProvider.AccessToken } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ diff --git a/internal/githubapp/githubapp.go b/internal/githubapp/githubapp.go new file mode 100644 index 0000000000..49072b93f0 --- /dev/null +++ b/internal/githubapp/githubapp.go @@ -0,0 +1,275 @@ +// Package githubapp implements non-interactive GitHub App server-to-server +// (s2s) authentication for the stdio server. +// +// Unlike the user-to-server OAuth flows in internal/oauth, this requires no +// human: no browser, no device code, no elicitation. It signs a short-lived +// JWT with the app's private key, exchanges it for an installation access +// token, and transparently refreshes that token before it expires. That makes +// it suitable for headless deployments — CI, Kubernetes, background agents. +// +// It only depends on the standard library and golang.org/x/oauth2. +// +// # Security +// +// This mode injects a long-lived, high-privilege credential (the app private +// key) into an environment shared with an AI agent, and the installation +// tokens it mints can act across every repository the app is installed on. It +// was added by popular demand for non-interactive deployments, but exposing +// credentials to agents — especially in the cloud — is dangerous and is not +// recommended without an independent security review. See +// docs/github-app-auth.md for the full guidance and least-privilege advice. +package githubapp + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // jwtLifetime is how long minted app JWTs are valid. GitHub rejects app JWTs + // whose exp is more than 10 minutes in the future; 9 minutes leaves headroom. + jwtLifetime = 9 * time.Minute + + // clockSkew backdates the JWT iat to tolerate small clock differences + // between this host and GitHub, which would otherwise reject the JWT. + clockSkew = 60 * time.Second + + // refreshBuffer refreshes installation tokens this long before their real + // expiry so an in-flight request never races the expiry boundary. + refreshBuffer = 5 * time.Minute + + // httpTimeout bounds each call to the installation token endpoint so a + // stalled GitHub API cannot block a tool call indefinitely. + httpTimeout = 30 * time.Second +) + +// Config describes a GitHub App installation used for server-to-server auth. +type Config struct { + // 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 + + // InstallationID identifies the installation whose access token is minted. + InstallationID string + + // PrivateKey signs the app JWT (RS256). Parse one with ParsePrivateKey. + PrivateKey *rsa.PrivateKey + + // BaseRESTURL is the REST API base, e.g. https://api.github.com/ for + // github.com or https://HOST/api/v3/ for GitHub Enterprise Server. + BaseRESTURL string +} + +// Validate reports whether the configuration is complete enough to mint tokens. +func (c Config) Validate() error { + switch { + case c.AppID == "": + return errors.New("GitHub App ID is required (GITHUB_APP_ID)") + case c.InstallationID == "": + return errors.New("GitHub App installation ID is required (GITHUB_APP_INSTALLATION_ID)") + case c.PrivateKey == nil: + return errors.New("GitHub App private key is required (GITHUB_APP_PRIVATE_KEY_PATH or GITHUB_APP_PRIVATE_KEY)") + case c.BaseRESTURL == "": + return errors.New("GitHub App REST base URL is required") + } + return nil +} + +// ParsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 ("RSA PRIVATE +// KEY") or PKCS#8 ("PRIVATE KEY") form — the two formats GitHub issues for app +// keys. +func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("no PEM block found in private key") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing private key (want PKCS#1 or PKCS#8 RSA): %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is %T, want an RSA key", parsed) + } + return key, nil +} + +// mintJWT builds and signs a short-lived app JWT (RS256) for the configured +// app, as required by the installation token endpoint. +func (c Config) mintJWT(now time.Time) (string, error) { + header := map[string]string{"alg": "RS256", "typ": "JWT"} + claims := map[string]any{ + "iat": now.Add(-clockSkew).Unix(), + "exp": now.Add(jwtLifetime).Unix(), + "iss": c.AppID, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("encoding JWT header: %w", err) + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("encoding JWT claims: %w", err) + } + + signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + + base64.RawURLEncoding.EncodeToString(claimsJSON) + + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +// installationTokenSource is an oauth2.TokenSource that mints GitHub App +// installation access tokens. It performs no caching itself; wrap it in +// oauth2.ReuseTokenSource (see NewProvider) for that. +type installationTokenSource struct { + cfg Config + httpClient *http.Client +} + +func newInstallationTokenSource(cfg Config, httpClient *http.Client) *installationTokenSource { + if httpClient == nil { + httpClient = &http.Client{Timeout: httpTimeout} + } + return &installationTokenSource{cfg: cfg, httpClient: httpClient} +} + +// Token mints a fresh installation access token. The returned token's Expiry is +// set refreshBuffer before the real expiry so callers refresh early. +func (s *installationTokenSource) Token() (*oauth2.Token, error) { + jwt, err := s.cfg.mintJWT(time.Now()) + if err != nil { + return nil, err + } + + endpoint, err := url.JoinPath(s.cfg.BaseRESTURL, "app", "installations", s.cfg.InstallationID, "access_tokens") + if err != nil { + return nil, fmt.Errorf("building installation token URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating installation token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting installation token: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + // The error body is GitHub's JSON message (never the token); include a + // bounded snippet to make misconfiguration diagnosable. + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet))) + } + + var body struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decoding installation token response: %w", err) + } + if body.Token == "" { + return nil, errors.New("installation token response did not contain a token") + } + + expiry := body.ExpiresAt + if !expiry.IsZero() { + expiry = expiry.Add(-refreshBuffer) + } + return &oauth2.Token{ + AccessToken: body.Token, + TokenType: "token", + Expiry: expiry, + }, nil +} + +// Provider supplies GitHub App installation access tokens, caching and +// refreshing them transparently. Its AccessToken method mirrors +// oauth.Manager.AccessToken so it can back BearerAuthTransport.TokenProvider. +type Provider struct { + source oauth2.TokenSource + logger *slog.Logger + + mu sync.Mutex + errLogged bool +} + +// NewProvider validates cfg and returns a Provider that mints and refreshes +// installation tokens. A nil logger logs to stderr. +func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + // ReuseTokenSource caches the token and only calls the underlying source + // once the cached token is expired. Because Token() backdates Expiry by + // refreshBuffer, that refresh happens ~5 minutes before the real expiry. + source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, nil)) + return &Provider{source: source, logger: logger}, nil +} + +// AccessToken returns a currently valid installation access token, refreshing +// it if needed, or "" if a token could not be obtained. A fetch failure is +// logged once (until the next success) so a misconfiguration is visible without +// flooding the log on every tool call. +func (p *Provider) AccessToken() string { + tok, err := p.source.Token() + if err != nil { + p.mu.Lock() + if !p.errLogged { + p.errLogged = true + p.logger.Error("failed to obtain GitHub App installation token", "error", err) + } + p.mu.Unlock() + return "" + } + p.mu.Lock() + p.errLogged = false + p.mu.Unlock() + return tok.AccessToken +} + +// HasToken reports whether a valid token can currently be obtained. +func (p *Provider) HasToken() bool { + return p.AccessToken() != "" +} diff --git a/internal/githubapp/githubapp_test.go b/internal/githubapp/githubapp_test.go new file mode 100644 index 0000000000..c2d5eeff7f --- /dev/null +++ b/internal/githubapp/githubapp_test.go @@ -0,0 +1,271 @@ +package githubapp + +import ( + "bytes" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return key +} + +func pkcs1PEM(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func pkcs8PEM(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +func TestParsePrivateKey(t *testing.T) { + key := newTestKey(t) + + t.Run("PKCS1", func(t *testing.T) { + got, err := ParsePrivateKey(pkcs1PEM(t, key)) + require.NoError(t, err) + assert.Equal(t, key.N, got.N) + }) + + t.Run("PKCS8", func(t *testing.T) { + got, err := ParsePrivateKey(pkcs8PEM(t, key)) + require.NoError(t, err) + assert.Equal(t, key.N, got.N) + }) + + t.Run("not PEM", func(t *testing.T) { + _, err := ParsePrivateKey([]byte("not a pem")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no PEM block") + }) + + t.Run("non-RSA key", func(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + + _, err = ParsePrivateKey(keyPEM) + require.Error(t, err) + assert.Contains(t, err.Error(), "want an RSA key") + }) +} + +func TestConfigValidate(t *testing.T) { + key := newTestKey(t) + base := Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: "https://api.github.com/"} + require.NoError(t, base.Validate()) + + tests := []struct { + name string + mutate func(c *Config) + want string + }{ + {"missing app id", func(c *Config) { c.AppID = "" }, "App ID is required"}, + {"missing installation id", func(c *Config) { c.InstallationID = "" }, "installation ID is required"}, + {"missing private key", func(c *Config) { c.PrivateKey = nil }, "private key is required"}, + {"missing base url", func(c *Config) { c.BaseRESTURL = "" }, "REST base URL is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := base + tt.mutate(&c) + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +// verifyJWT parses and verifies an app JWT against the public key and returns +// its claims, asserting the structural requirements GitHub enforces. +func verifyJWT(t *testing.T, token string, pub *rsa.PublicKey) map[string]any { + t.Helper() + parts := strings.Split(token, ".") + require.Len(t, parts, 3, "JWT must have three segments") + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + var header map[string]string + require.NoError(t, json.Unmarshal(headerJSON, &header)) + assert.Equal(t, "RS256", header["alg"]) + assert.Equal(t, "JWT", header["typ"]) + + signingInput := parts[0] + "." + parts[1] + digest := sha256.Sum256([]byte(signingInput)) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], signature), "signature must verify") + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]any + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + return claims +} + +func TestMintJWT(t *testing.T) { + key := newTestKey(t) + cfg := Config{AppID: "my-app-id", PrivateKey: key} + + now := time.Now() + token, err := cfg.mintJWT(now) + require.NoError(t, err) + + claims := verifyJWT(t, token, &key.PublicKey) + assert.Equal(t, "my-app-id", claims["iss"]) + + iat := int64(claims["iat"].(float64)) + exp := int64(claims["exp"].(float64)) + assert.Equal(t, now.Add(-clockSkew).Unix(), iat, "iat should be backdated by the clock skew") + assert.Equal(t, now.Add(jwtLifetime).Unix(), exp) + assert.LessOrEqual(t, exp-iat, int64((10 * time.Minute).Seconds()), "JWT must live no longer than GitHub's 10 minute cap") +} + +// installationServer is a fake installation token endpoint that verifies the +// app JWT and returns a token expiring at expiresAt. It counts mint requests. +func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresAt time.Time) (*httptest.Server, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/app/installations/456/access_tokens", r.URL.Path) + + authz := r.Header.Get("Authorization") + require.True(t, strings.HasPrefix(authz, "Bearer "), "must send the app JWT as a bearer token") + verifyJWT(t, strings.TrimPrefix(authz, "Bearer "), pub) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "expires_at": expiresAt.UTC().Format(time.RFC3339), + }) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func newTestConfig(key *rsa.PrivateKey, baseURL string) Config { + return Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: baseURL + "/"} +} + +func TestProviderFetchesToken(t *testing.T) { + key := newTestKey(t) + srv, calls := installationServer(t, &key.PublicKey, "ghs_fresh", time.Now().Add(time.Hour)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + assert.Equal(t, "ghs_fresh", provider.AccessToken()) + assert.True(t, provider.HasToken()) + assert.Equal(t, int32(1), calls.Load()) +} + +func TestProviderCachesToken(t *testing.T) { + key := newTestKey(t) + srv, calls := installationServer(t, &key.PublicKey, "ghs_cached", time.Now().Add(time.Hour)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + for range 3 { + assert.Equal(t, "ghs_cached", provider.AccessToken()) + } + assert.Equal(t, int32(1), calls.Load(), "a token valid for an hour should be minted only once") +} + +func TestProviderRefreshesNearExpiry(t *testing.T) { + key := newTestKey(t) + // expires within the refresh buffer, so the stored expiry is already in the + // past and every call re-mints. + srv, calls := installationServer(t, &key.PublicKey, "ghs_short", time.Now().Add(refreshBuffer-time.Minute)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + assert.Equal(t, "ghs_short", provider.AccessToken()) + assert.Equal(t, "ghs_short", provider.AccessToken()) + assert.Equal(t, int32(2), calls.Load(), "a token expiring within the refresh buffer should re-mint each call") +} + +func TestProviderErrorLoggedOnce(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"A JSON web token could not be decoded"}`)) + })) + t.Cleanup(srv.Close) + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) + provider, err := NewProvider(newTestConfig(key, srv.URL), logger) + require.NoError(t, err) + + assert.Empty(t, provider.AccessToken()) + assert.Empty(t, provider.AccessToken()) + assert.Equal(t, 1, strings.Count(logBuf.String(), "failed to obtain GitHub App installation token"), + "a repeated fetch failure should only be logged once") +} + +func TestProviderErrorIncludesStatus(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + t.Cleanup(srv.Close) + + source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) + _, err := source.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + assert.Contains(t, err.Error(), "Not Found") +} + +func TestNewProviderValidates(t *testing.T) { + _, err := NewProvider(Config{}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "App ID is required") +} + +// Ensure the source returns an error rather than panicking on a token-less 201. +func TestSourceRejectsEmptyToken(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, `{"expires_at":"2099-01-01T00:00:00Z"}`) + })) + t.Cleanup(srv.Close) + + source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) + _, err := source.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "did not contain a token") +}