Skip to content

Commit 6799ef5

Browse files
committed
fix: address Copilot review feedback
- 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
1 parent f3ad678 commit 6799ef5

5 files changed

Lines changed: 72 additions & 45 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ The server automatically generates JWTs, fetches installation tokens, and refres
254254
| `GITHUB_APP_PRIVATE_KEY` | The PEM-encoded private key (inline, `\n` for newlines) |
255255
| `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key file (alternative to inline) |
256256

257-
Either `GITHUB_APP_PRIVATE_KEY` or `GITHUB_APP_PRIVATE_KEY_PATH` must be set, but not both. When all three required variables (`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and a private key) are set, the server uses GitHub App authentication instead of a PAT. `GITHUB_PERSONAL_ACCESS_TOKEN` is not required in this case.
257+
Either `GITHUB_APP_PRIVATE_KEY` or `GITHUB_APP_PRIVATE_KEY_PATH` must be set, but not both (they are mutually exclusive). When all three required variables (`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and a private key) are set, the server uses GitHub App authentication instead of a PAT. `GITHUB_PERSONAL_ACCESS_TOKEN` is not required in this case.
258258

259259
#### Example: Using a private key file
260260

cmd/github-mcp-server/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64,
266266
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")
267267
}
268268

269+
if privateKeyStr != "" && privateKeyPath != "" {
270+
return 0, nil, 0, errors.New("GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are mutually exclusive")
271+
}
272+
269273
appID, err = strconv.ParseInt(appIDStr, 10, 64)
270274
if err != nil {
271275
return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_ID: %w", err)
@@ -277,8 +281,13 @@ func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64,
277281
}
278282

279283
if privateKeyStr != "" {
280-
// Environment variables often use literal "\n" instead of actual newlines
281-
privateKey = []byte(strings.ReplaceAll(privateKeyStr, `\n`, "\n"))
284+
// Environment variables often use literal "\n" instead of actual newlines.
285+
// Only replace when the value has no real newlines to avoid corrupting
286+
// keys that were correctly passed with actual newlines.
287+
if !strings.Contains(privateKeyStr, "\n") {
288+
privateKeyStr = strings.ReplaceAll(privateKeyStr, `\n`, "\n")
289+
}
290+
privateKey = []byte(privateKeyStr)
282291
} else {
283292
privateKey, err = os.ReadFile(privateKeyPath)
284293
if err != nil {

internal/ghmcp/server.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,13 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
9595
// We use NewEnterpriseClient unconditionally since we already parsed the API host
9696
var gqlTransport http.RoundTripper
9797
if authTransport != nil {
98-
// Auth transport already sets the Authorization header
99-
gqlTransport = &transport.GraphQLFeaturesTransport{
100-
Transport: authTransport,
98+
// Auth transport already sets the Authorization header.
99+
// Wrap with UserAgentTransport for consistency with the REST path.
100+
gqlTransport = &transport.UserAgentTransport{
101+
Transport: &transport.GraphQLFeaturesTransport{
102+
Transport: authTransport,
103+
},
104+
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
101105
}
102106
} else {
103107
gqlTransport = &transport.BearerAuthTransport{

pkg/github/appauth/appauth.go

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"fmt"
1414
"io"
1515
"net/http"
16-
"strings"
1716
"sync"
1817
"time"
1918
)
@@ -42,7 +41,7 @@ type Transport struct {
4241
key *rsa.PrivateKey
4342
base http.RoundTripper
4443

45-
mu sync.Mutex
44+
mu sync.RWMutex
4645
token string
4746
exp time.Time
4847
}
@@ -55,6 +54,8 @@ type installationToken struct {
5554
// NewTransport creates a new Transport that authenticates using a GitHub App
5655
// installation token. The transport automatically handles JWT generation and
5756
// installation token refresh.
57+
// The base transport must not inject its own Authorization header, as this
58+
// transport sets it for both installation token requests and API requests.
5859
func NewTransport(base http.RoundTripper, cfg Config) (*Transport, error) {
5960
key, err := parsePrivateKey(cfg.PrivateKey)
6061
if err != nil {
@@ -91,10 +92,20 @@ func (t *Transport) Token(ctx context.Context) (string, error) {
9192
}
9293

9394
func (t *Transport) installationToken(ctx context.Context) (string, error) {
95+
// Fast path: read lock to check cached token
96+
t.mu.RLock()
97+
if t.token != "" && time.Now().Add(5*time.Minute).Before(t.exp) {
98+
token := t.token
99+
t.mu.RUnlock()
100+
return token, nil
101+
}
102+
t.mu.RUnlock()
103+
104+
// Slow path: write lock to refresh
94105
t.mu.Lock()
95106
defer t.mu.Unlock()
96107

97-
// Refresh if the token expires within 5 minutes
108+
// Double-check after acquiring write lock
98109
if t.token != "" && time.Now().Add(5*time.Minute).Before(t.exp) {
99110
return t.token, nil
100111
}
@@ -116,15 +127,15 @@ func (t *Transport) installationToken(ctx context.Context) (string, error) {
116127

117128
// generateJWT creates a signed JWT for GitHub App authentication using RS256.
118129
func (t *Transport) generateJWT() (string, error) {
119-
now := time.Now().Add(-30 * time.Second) // allow 30s clock drift
130+
now := time.Now()
120131

121132
header := map[string]string{
122133
"alg": "RS256",
123134
"typ": "JWT",
124135
}
125136
payload := map[string]any{
126-
"iat": now.Unix(),
127-
"exp": now.Add(10 * time.Minute).Unix(),
137+
"iat": now.Add(-30 * time.Second).Unix(), // allow 30s clock drift
138+
"exp": now.Add(9 * time.Minute).Unix(), // well within GitHub's 10-minute maximum
128139
"iss": fmt.Sprintf("%d", t.config.AppID),
129140
}
130141

@@ -201,33 +212,3 @@ func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
201212
return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
202213
}
203214
}
204-
205-
// VerifyJWT parses and verifies a JWT token using the given RSA public key.
206-
// Returns the claims map. This is used only for testing.
207-
func VerifyJWT(tokenString string, pubKey *rsa.PublicKey) (map[string]any, error) {
208-
parts := strings.SplitN(tokenString, ".", 3)
209-
if len(parts) != 3 {
210-
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
211-
}
212-
213-
signingInput := parts[0] + "." + parts[1]
214-
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
215-
if err != nil {
216-
return nil, fmt.Errorf("failed to decode signature: %w", err)
217-
}
218-
219-
hash := sha256.Sum256([]byte(signingInput))
220-
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sig); err != nil {
221-
return nil, fmt.Errorf("invalid signature: %w", err)
222-
}
223-
224-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
225-
if err != nil {
226-
return nil, fmt.Errorf("failed to decode payload: %w", err)
227-
}
228-
var claims map[string]any
229-
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
230-
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
231-
}
232-
return claims, nil
233-
}

pkg/github/appauth/appauth_test.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package appauth
22

33
import (
44
"context"
5+
"crypto"
56
"crypto/rand"
67
"crypto/rsa"
8+
"crypto/sha256"
79
"crypto/x509"
10+
"encoding/base64"
811
"encoding/json"
912
"encoding/pem"
1013
"fmt"
1114
"net/http"
1215
"net/http/httptest"
16+
"strings"
1317
"sync/atomic"
1418
"testing"
1519
"time"
@@ -18,6 +22,35 @@ import (
1822
"github.com/stretchr/testify/require"
1923
)
2024

25+
// verifyJWT parses and verifies a JWT token using the given RSA public key.
26+
func verifyJWT(tokenString string, pubKey *rsa.PublicKey) (map[string]any, error) {
27+
parts := strings.SplitN(tokenString, ".", 3)
28+
if len(parts) != 3 {
29+
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
30+
}
31+
32+
signingInput := parts[0] + "." + parts[1]
33+
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to decode signature: %w", err)
36+
}
37+
38+
hash := sha256.Sum256([]byte(signingInput))
39+
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sig); err != nil {
40+
return nil, fmt.Errorf("invalid signature: %w", err)
41+
}
42+
43+
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
44+
if err != nil {
45+
return nil, fmt.Errorf("failed to decode payload: %w", err)
46+
}
47+
var claims map[string]any
48+
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
49+
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
50+
}
51+
return claims, nil
52+
}
53+
2154
func generateTestKey(t *testing.T) (*rsa.PrivateKey, []byte) {
2255
t.Helper()
2356
key, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -111,15 +144,15 @@ func TestTransport_GenerateJWT(t *testing.T) {
111144
jwtToken, err := tr.generateJWT()
112145
require.NoError(t, err)
113146

114-
claims, err := VerifyJWT(jwtToken, &key.PublicKey)
147+
claims, err := verifyJWT(jwtToken, &key.PublicKey)
115148
require.NoError(t, err)
116149

117150
assert.Equal(t, "12345", claims["iss"])
118151

119152
iat := int64(claims["iat"].(float64))
120153
exp := int64(claims["exp"].(float64))
121154
assert.InDelta(t, time.Now().Unix(), iat, 60)
122-
assert.InDelta(t, time.Now().Add(10*time.Minute).Unix(), exp, 60)
155+
assert.InDelta(t, time.Now().Add(9*time.Minute).Unix(), exp, 60)
123156
}
124157

125158
func TestTransport_FetchInstallationToken(t *testing.T) {
@@ -135,7 +168,7 @@ func TestTransport_FetchInstallationToken(t *testing.T) {
135168
assert.True(t, len(authHeader) > 7)
136169
jwtToken := authHeader[7:] // strip "Bearer "
137170

138-
_, err := VerifyJWT(jwtToken, &key.PublicKey)
171+
_, err := verifyJWT(jwtToken, &key.PublicKey)
139172
assert.NoError(t, err)
140173

141174
w.WriteHeader(http.StatusCreated)

0 commit comments

Comments
 (0)