feat: add GitHub App authentication support#2562
Conversation
Add native GitHub App authentication as an alternative to Personal Access Tokens. The server can now authenticate using App ID, private key, and installation ID to automatically generate and refresh installation tokens. - Add `pkg/github/appauth` package with JWT generation and installation token management using only the standard library - Auto-refresh tokens before expiry (5-minute buffer on 1-hour tokens) - Support private key via env var (GITHUB_APP_PRIVATE_KEY) or file path (GITHUB_APP_PRIVATE_KEY_PATH) - Handle literal `\n` in env var PEM keys - Add comprehensive tests (13 tests covering key parsing, JWT generation, token caching, refresh, round-trip, and error handling) Closes github#1333
gogithub.Option is not an exported type, so we cannot store options in a slice. Use separate NewClient calls for each auth path instead.
sharmaz11
left a comment
There was a problem hiding this comment.
Security test - please ignore - bug bounty testing
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds GitHub App installation authentication as an alternative to Personal Access Tokens for the stdio MCP server. A new appauth package implements an http.RoundTripper that generates JWTs and fetches/caches installation tokens; the server wiring conditionally uses this transport when App credentials are supplied via environment variables.
Changes:
- New
pkg/github/appauthpackage (Transport + JWT/installation token logic + tests). internal/ghmcp/server.goplumbs an optionalauthTransportthroughNewStdioMCPServer/createGitHubClientsand skips PAT-only scope fetching when App auth is in use.- CLI (
cmd/github-mcp-server/main.go) parsesGITHUB_APP_ID/GITHUB_APP_INSTALLATION_ID/GITHUB_APP_PRIVATE_KEY[_PATH]env vars; README documents the feature.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/github/appauth/appauth.go | New transport that signs JWTs and exchanges them for installation tokens. |
| pkg/github/appauth/appauth_test.go | Unit tests covering key parsing, JWT generation, token caching/refresh, and RoundTrip. |
| internal/ghmcp/server.go | Threads optional App auth transport into REST/GraphQL client construction. |
| cmd/github-mcp-server/main.go | Reads App auth config from env vars and passes to RunStdioServer. |
| go.sum | Adds golang-jwt/jwt/v4 checksum entry. |
| README.md | Documents GitHub App authentication usage. |
| // Environment variables often use literal "\n" instead of actual newlines | ||
| privateKey = []byte(strings.ReplaceAll(privateKeyStr, `\n`, "\n")) |
There was a problem hiding this comment.
Fixed. Now only replaces literal \n when the value contains no actual newlines, so keys correctly passed via .env files or heredocs are not affected.
| func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64, err error) { | ||
| appIDStr := viper.GetString("app_id") | ||
| installationIDStr := viper.GetString("app_installation_id") | ||
| privateKeyStr := viper.GetString("app_private_key") | ||
| privateKeyPath := viper.GetString("app_private_key_path") |
There was a problem hiding this comment.
These env vars are correctly picked up via viper.AutomaticEnv() with the GITHUB_ prefix. For example, viper.GetString("app_id") reads GITHUB_APP_ID because the env prefix is set to github and the key replacer converts - to _. No explicit BindEnv is needed for this case.
| if appIDStr == "" || installationIDStr == "" || (privateKeyStr == "" && privateKeyPath == "") { | ||
| return 0, nil, 0, errors.New("incomplete GitHub App auth config: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH are all required") | ||
| } |
There was a problem hiding this comment.
Fixed. Now returns an error when both GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are set. README also updated to state they are mutually exclusive.
| // Auth transport already sets the Authorization header | ||
| gqlTransport = &transport.GraphQLFeaturesTransport{ | ||
| Transport: authTransport, |
There was a problem hiding this comment.
Fixed. Wrapped the GraphQL transport in UserAgentTransport when using App auth, matching the REST path behavior.
| req.Header.Set("Authorization", "Bearer "+jwtToken) | ||
| req.Header.Set("Accept", "application/vnd.github+json") | ||
|
|
||
| resp, err := t.base.RoundTrip(req) |
There was a problem hiding this comment.
Added a doc comment on NewTransport clarifying that the base transport must not inject its own Authorization header.
| func (t *Transport) installationToken(ctx context.Context) (string, error) { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() |
There was a problem hiding this comment.
Fixed. Switched to sync.RWMutex with a double-check pattern: cached-token reads use RLock (concurrent), and only the refresh path acquires Lock. This avoids serializing all requests behind a slow token refresh.
| // VerifyJWT parses and verifies a JWT token using the given RSA public key. | ||
| // Returns the claims map. This is used only for testing. | ||
| func VerifyJWT(tokenString string, pubKey *rsa.PublicKey) (map[string]any, error) { |
There was a problem hiding this comment.
Fixed. Moved VerifyJWT to the test file as unexported verifyJWT.
| now := time.Now().Add(-30 * time.Second) // allow 30s clock drift | ||
|
|
||
| header := map[string]string{ | ||
| "alg": "RS256", | ||
| "typ": "JWT", | ||
| } | ||
| payload := map[string]any{ | ||
| "iat": now.Unix(), | ||
| "exp": now.Add(10 * time.Minute).Unix(), |
There was a problem hiding this comment.
Fixed. Changed to iat = now - 30s, exp = now + 9m to stay well within the 10-minute maximum.
- Move VerifyJWT to test file to avoid exporting test-only helpers - Use RWMutex with double-check pattern to avoid blocking reads during token refresh - Add UserAgentTransport to GraphQL path when using App auth for consistency with REST - Make GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH mutually exclusive (return error when both are set) - Only replace literal \n when the private key has no real newlines to avoid corrupting correctly-passed keys - Use safer JWT lifetime (iat=now-30s, exp=now+9m) to stay well within GitHub's 10-minute maximum - Document that base transport must not inject its own Authorization header
Summary
Adds native GitHub App authentication as an alternative to Personal Access Tokens, addressing a common request for organization-managed, scoped, short-lived credentials.
pkg/github/appauthpackage that handles JWT generation (RS256) and installation token management using only the Go standard library (no new dependencies)sync.MutexGITHUB_APP_PRIVATE_KEY, with literal\nhandling) or file path (GITHUB_APP_PRIVATE_KEY_PATH)Environment Variables
GITHUB_APP_IDGITHUB_APP_INSTALLATION_IDGITHUB_APP_PRIVATE_KEY\nfor newlines)GITHUB_APP_PRIVATE_KEY_PATHCloses #1333
Test plan
--gh-host)