refactor(auth): isolate GitHub App auth to stdio startup

Keep PEM loading and installation-token provider construction at the CLI leaf, then pass a generic refreshing token provider through the existing HTTP transports. Rebase the feature onto current main and keep the HTTP command unchanged.

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

Copilot-Session: 646357dd-c89f-4973-9a5c-e6c5fc18818c
This commit is contained in:
Sam Morrow
2026-07-22 15:36:23 +02:00
parent e05a38403f
commit ea4e3960b8
11 changed files with 183 additions and 441 deletions
+1 -1
View File
@@ -311,7 +311,7 @@ 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.
For non-interactive stdio deployments, see **[GitHub App Authentication](docs/github-app-auth.md)**.
**Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth):
+12 -38
View File
@@ -40,11 +40,6 @@ 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")
@@ -59,14 +54,13 @@ 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. App auth opts out of this
// default so configuring an app never accidentally enables OAuth login too.
// id with no secret never picks up the baked-in secret.
if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
oauthClientID = buildinfo.OAuthClientID
oauthClientSecret = buildinfo.OAuthClientSecret
}
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")
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, 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")
@@ -137,7 +131,6 @@ 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.
// Skipped for GitHub App auth, which sources tokens non-interactively.
if token == "" && !appAuthRequested {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
@@ -156,15 +149,12 @@ 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"))
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
}
stdioServerConfig.AppAuth = appConfig
stdioServerConfig.TokenProvider = tokenProvider
}
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -263,11 +253,7 @@ 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.
// The private key has no flag because passing it in argv would expose it.
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")
@@ -326,19 +312,11 @@ 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) {
func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, 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 {
@@ -349,22 +327,18 @@ func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string)
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
}
cfg := &githubapp.Config{
provider, err := githubapp.NewProvider(githubapp.Config{
AppID: appID,
InstallationID: installationID,
PrivateKey: privateKey,
PrivateKeyPEM: keyBytes,
BaseRESTURL: restURL.String(),
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
return provider.AccessToken, 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 != "":
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadAppPrivateKey(t *testing.T) {
t.Run("file", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.pem")
require.NoError(t, os.WriteFile(path, []byte("from-file"), 0o600))
key, err := loadAppPrivateKey(path, "from-inline")
require.NoError(t, err)
assert.Equal(t, []byte("from-file"), key)
})
t.Run("inline", func(t *testing.T) {
key, err := loadAppPrivateKey("", `first\nsecond`)
require.NoError(t, err)
assert.Equal(t, []byte("first\nsecond"), key)
})
t.Run("missing", func(t *testing.T) {
_, err := loadAppPrivateKey("", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "private key")
})
}
func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
assert.NotNil(t, stdioCmd.Flags().Lookup("app-id"))
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
}
+33 -221
View File
@@ -1,143 +1,34 @@
# GitHub App Server-to-Server Authentication (stdio)
# GitHub App authentication
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.
The local stdio server can authenticate as a GitHub App installation without a
browser, device flow, or elicitation. It signs a short-lived JWT with the app's
private key, exchanges it for an installation access token, and refreshes the
token before it expires.
This authentication mode is not available for the `http` command. HTTP clients
must continue to provide their own `Authorization` token.
> [!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.
> The private key can mint tokens for every repository and permission granted to
> the installation. Keep it out of source control, restrict access to the server
> process, and install the app only on the repositories it needs.
## Contents
## Configuration
- [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.
Configure exactly one of a Personal Access Token, OAuth login, or GitHub App
authentication.
| 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. |
| `--app-id` | `GITHUB_APP_ID` | App ID or client ID used as the JWT issuer |
| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation whose access token is used |
| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM |
| _(none)_ | `GITHUB_APP_PRIVATE_KEY` | PEM contents, optionally with literal `\n` escapes |
There is intentionally **no flag** for the private key contents: a flag would
place the key in the process's command line (`ps`, `/proc/<pid>/cmdline`), where
other processes could read it.
A mounted private-key file is preferred. There is no flag for inline PEM
contents because command-line arguments may be visible to other processes.
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:
## Usage
```bash
github-mcp-server stdio \
@@ -146,7 +37,7 @@ github-mcp-server stdio \
--app-private-key-path /secrets/github-app.pem
```
Equivalently, with environment variables:
The equivalent environment configuration is:
```bash
export GITHUB_APP_ID=123456
@@ -155,7 +46,7 @@ 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):
For Docker, mount the key read-only:
```bash
docker run -i --rm \
@@ -166,96 +57,17 @@ docker run -i --rm \
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).
For GitHub Enterprise Server or `ghe.com`, also set `--gh-host` or
`GITHUB_HOST`. The server derives the installation-token endpoint from that
host.
## Troubleshooting
- **`GitHub App authentication requires a private key`** — you set some `app-*`
values but no key. Set `GITHUB_APP_PRIVATE_KEY_PATH` (preferred) or
- **Private key required**: set `GITHUB_APP_PRIVATE_KEY_PATH` 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.
- **Invalid private key**: provide the RSA PEM generated in the GitHub App
settings. PKCS#1 and PKCS#8 keys are supported.
- **401 from the installation-token endpoint**: verify the app ID or client ID,
private key, target host, and system clock.
- **404 from the installation-token endpoint**: verify the installation ID and
that the app is installed on the target host.
+2 -6
View File
@@ -15,12 +15,8 @@ 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.
> For non-interactive stdio deployments, see
> [GitHub App authentication](github-app-auth.md).
## Contents
+6 -13
View File
@@ -9,7 +9,6 @@ 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"
@@ -539,10 +538,6 @@ func TestOAuthMultiRoundTripResultType(t *testing.T) {
assert.False(t, toolRan)
}
// 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()
@@ -557,12 +552,12 @@ func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) {
cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr},
},
{
name: "token and app",
cfg: StdioServerConfig{Token: "ghp_static", AppAuth: &githubapp.Config{}},
name: "token and provider",
cfg: StdioServerConfig{Token: "ghp_static", TokenProvider: func() string { return "token" }},
},
{
name: "oauth and app",
cfg: StdioServerConfig{OAuthManager: mgr, AppAuth: &githubapp.Config{}},
name: "oauth and provider",
cfg: StdioServerConfig{OAuthManager: mgr, TokenProvider: func() string { return "token" }},
},
}
for _, tt := range tests {
@@ -575,10 +570,8 @@ func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) {
}
}
// TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a
// TokenProvider is configured the REST client authenticates with the provider's
// current token on every request (and never pins a stale one), which is what the
// lazy, refreshing OAuth token depends on.
// TestCreateGitHubClientsTokenProvider verifies that clients resolve the
// provider for every request instead of pinning a token.
func TestCreateGitHubClientsTokenProvider(t *testing.T) {
t.Parallel()
+7 -38
View File
@@ -12,7 +12,6 @@ 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"
@@ -63,7 +62,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
return nil, fmt.Errorf("failed to get Raw URL: %w", err)
}
// Construct REST client. When a TokenProvider is configured (OAuth), we
// Construct REST client. When a TokenProvider is configured, we
// authenticate via BearerAuthTransport and skip go-github's WithAuthToken:
// the latter installs its own round tripper that would pin the static token
// and shadow the dynamic one.
@@ -259,30 +258,20 @@ type StdioServerConfig struct {
// 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
// TokenProvider supplies a token for each GitHub API request.
TokenProvider func() string
}
// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
// 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} {
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != 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)")
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, or TokenProvider")
}
// Create app context
@@ -307,20 +296,6 @@ 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
@@ -342,17 +317,11 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger.Debug("skipping scope filtering for non-PAT token")
}
// 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
tokenProvider := cfg.TokenProvider
var toolHandlerMiddleware []inventory.ToolHandlerMiddleware
switch {
case cfg.OAuthManager != nil:
if cfg.OAuthManager != nil {
tokenProvider = cfg.OAuthManager.AccessToken
toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger))
case appProvider != nil:
tokenProvider = appProvider.AccessToken
}
ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
+35 -89
View File
@@ -1,23 +1,4 @@
// 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 provides GitHub App installation access tokens.
package githubapp
import (
@@ -36,7 +17,6 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -45,48 +25,35 @@ import (
)
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.
jwtLifetime = 9 * time.Minute
clockSkew = time.Minute
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
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 is used as the JWT issuer. GitHub accepts an app ID or client ID.
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
// PrivateKeyPEM is the RSA key used to sign app JWTs.
PrivateKeyPEM []byte
// 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 {
func (c Config) validate() error {
switch {
case c.AppID == "":
return errors.New("GitHub App ID is required (GITHUB_APP_ID)")
return errors.New("GitHub App ID or client 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:
case len(c.PrivateKeyPEM) == 0:
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")
@@ -94,10 +61,7 @@ func (c Config) Validate() error {
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) {
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")
@@ -116,14 +80,12 @@ func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
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) {
func mintJWT(appID string, privateKey *rsa.PrivateKey, 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,
"iss": appID,
}
headerJSON, err := json.Marshal(header)
@@ -139,7 +101,7 @@ func (c Config) mintJWT(now time.Time) (string, error) {
base64.RawURLEncoding.EncodeToString(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, digest[:])
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("signing JWT: %w", err)
}
@@ -147,25 +109,21 @@ func (c Config) mintJWT(now time.Time) (string, error) {
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
privateKey *rsa.PrivateKey
httpClient *http.Client
}
func newInstallationTokenSource(cfg Config, httpClient *http.Client) *installationTokenSource {
func newInstallationTokenSource(cfg Config, privateKey *rsa.PrivateKey, httpClient *http.Client) *installationTokenSource {
if httpClient == nil {
httpClient = &http.Client{Timeout: httpTimeout}
}
return &installationTokenSource{cfg: cfg, httpClient: httpClient}
return &installationTokenSource{cfg: cfg, privateKey: privateKey, 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())
jwt, err := mintJWT(s.cfg.AppID, s.privateKey, time.Now())
if err != nil {
return nil, err
}
@@ -193,9 +151,10 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) {
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))
snippet, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
if readErr != nil {
return nil, fmt.Errorf("installation token request failed: %s (reading response: %w)", resp.Status, readErr)
}
return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet)))
}
@@ -209,21 +168,17 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) {
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)
if body.ExpiresAt.IsZero() {
return nil, errors.New("installation token response did not contain an expiry")
}
return &oauth2.Token{
AccessToken: body.Token,
TokenType: "token",
Expiry: expiry,
Expiry: body.ExpiresAt.Add(-refreshBuffer),
}, 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.
// Provider caches and refreshes GitHub App installation access tokens.
type Provider struct {
source oauth2.TokenSource
logger *slog.Logger
@@ -232,26 +187,22 @@ type Provider struct {
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 {
if err := cfg.validate(); err != nil {
return nil, err
}
if logger == nil {
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
privateKey, err := parsePrivateKey(cfg.PrivateKeyPEM)
if err != nil {
return nil, fmt.Errorf("invalid GitHub App private key: %w", err)
}
// 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))
if logger == nil {
logger = slog.Default()
}
source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, privateKey, 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.
// AccessToken returns a cached token or refreshes it before expiry.
func (p *Provider) AccessToken() string {
tok, err := p.source.Token()
if err != nil {
@@ -268,8 +219,3 @@ func (p *Provider) AccessToken() string {
p.mu.Unlock()
return tok.AccessToken
}
// HasToken reports whether a valid token can currently be obtained.
func (p *Provider) HasToken() bool {
return p.AccessToken() != ""
}
+47 -28
View File
@@ -33,7 +33,7 @@ func newTestKey(t *testing.T) *rsa.PrivateKey {
func pkcs1PEM(t *testing.T, key *rsa.PrivateKey) []byte {
t.Helper()
return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return pkcs1PEMBytes(key)
}
func pkcs8PEM(t *testing.T, key *rsa.PrivateKey) []byte {
@@ -47,19 +47,19 @@ func TestParsePrivateKey(t *testing.T) {
key := newTestKey(t)
t.Run("PKCS1", func(t *testing.T) {
got, err := ParsePrivateKey(pkcs1PEM(t, key))
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))
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"))
_, err := parsePrivateKey([]byte("not a pem"))
require.Error(t, err)
assert.Contains(t, err.Error(), "no PEM block")
})
@@ -71,7 +71,7 @@ func TestParsePrivateKey(t *testing.T) {
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
_, err = ParsePrivateKey(keyPEM)
_, err = parsePrivateKey(keyPEM)
require.Error(t, err)
assert.Contains(t, err.Error(), "want an RSA key")
})
@@ -79,24 +79,24 @@ func TestParsePrivateKey(t *testing.T) {
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())
base := Config{AppID: "123", InstallationID: "456", PrivateKeyPEM: pkcs1PEM(t, 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 app id", func(c *Config) { c.AppID = "" }, "App ID or client 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 private key", func(c *Config) { c.PrivateKeyPEM = 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()
err := c.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
@@ -132,10 +132,8 @@ func verifyJWT(t *testing.T, token string, pub *rsa.PublicKey) map[string]any {
func TestMintJWT(t *testing.T) {
key := newTestKey(t)
cfg := Config{AppID: "my-app-id", PrivateKey: key}
now := time.Now()
token, err := cfg.mintJWT(now)
token, err := mintJWT("my-app-id", key, now)
require.NoError(t, err)
claims := verifyJWT(t, token, &key.PublicKey)
@@ -173,7 +171,18 @@ func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresA
}
func newTestConfig(key *rsa.PrivateKey, baseURL string) Config {
return Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: baseURL + "/"}
return Config{AppID: "123", InstallationID: "456", PrivateKeyPEM: pkcs1PEMBytes(key), BaseRESTURL: baseURL + "/"}
}
func pkcs1PEMBytes(key *rsa.PrivateKey) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
}
func newTestTokenSource(t *testing.T, cfg Config, client *http.Client) *installationTokenSource {
t.Helper()
privateKey, err := parsePrivateKey(cfg.PrivateKeyPEM)
require.NoError(t, err)
return newInstallationTokenSource(cfg, privateKey, client)
}
func TestProviderFetchesToken(t *testing.T) {
@@ -184,7 +193,6 @@ func TestProviderFetchesToken(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "ghs_fresh", provider.AccessToken())
assert.True(t, provider.HasToken())
assert.Equal(t, int32(1), calls.Load())
}
@@ -242,7 +250,7 @@ func TestProviderErrorIncludesStatus(t *testing.T) {
}))
t.Cleanup(srv.Close)
source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client())
source := newTestTokenSource(t, newTestConfig(key, srv.URL), srv.Client())
_, err := source.Token()
require.Error(t, err)
assert.Contains(t, err.Error(), "404")
@@ -252,20 +260,31 @@ func TestProviderErrorIncludesStatus(t *testing.T) {
func TestNewProviderValidates(t *testing.T) {
_, err := NewProvider(Config{}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "App ID is required")
assert.Contains(t, err.Error(), "App ID or client ID is required")
}
// Ensure the source returns an error rather than panicking on a token-less 201.
func TestSourceRejectsEmptyToken(t *testing.T) {
func TestSourceRejectsIncompleteTokenResponse(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)
tests := []struct {
name string
body string
want string
}{
{name: "missing token", body: `{"expires_at":"2099-01-01T00:00:00Z"}`, want: "did not contain a token"},
{name: "missing expiry", body: `{"token":"ghs_token"}`, want: "did not contain an expiry"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, tt.body)
}))
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")
source := newTestTokenSource(t, newTestConfig(key, srv.URL), srv.Client())
_, err := source.Token()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
+1 -2
View File
@@ -69,8 +69,7 @@ type MCPServerConfig struct {
TokenScopes []string
// TokenProvider, when non-nil, supplies the GitHub token for each API
// request instead of the static Token. It backs OAuth login, where the
// token is obtained lazily on first use and refreshed thereafter.
// request instead of the static Token.
TokenProvider func() string
// ToolHandlerMiddleware wraps every registered tool handler. Unlike MCP
+1 -5
View File
@@ -13,9 +13,7 @@ type BearerAuthTransport struct {
Token string
// TokenProvider, when non-nil, supplies the bearer token for each request
// and takes precedence over Token. It backs OAuth, where the token is
// obtained after the client is built and is refreshed over the session's
// lifetime. It may return an empty string before authorization completes.
// and takes precedence over Token.
TokenProvider func() string
}
@@ -25,8 +23,6 @@ func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
if t.TokenProvider != nil {
token = t.TokenProvider()
}
// Before OAuth authorization completes the token is empty; send an
// unauthenticated request rather than an empty "Bearer " header.
if token != "" {
req.Header.Set(headers.AuthorizationHeader, "Bearer "+token)
}