Skip to content

Commit 6ea3eec

Browse files
test(oauth): add comprehensive Manager tests
- Add TestNewManager, TestManagerHasToken, TestManagerGetAccessToken - Add TestManagerSetToken, TestGenerateState, TestGenerateElicitationID - Add test cases for GHEC (ghe.com) and host without scheme - Improve test documentation with comments
1 parent 06d8b47 commit 6ea3eec

File tree

3 files changed

+278
-5
lines changed

3 files changed

+278
-5
lines changed

docs/oauth-authentication.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# OAuth Authentication
2+
3+
The GitHub MCP Server supports OAuth authentication for stdio mode, enabling interactive authentication when no Personal Access Token (PAT) is configured.
4+
5+
## Overview
6+
7+
OAuth authentication allows users to authenticate with GitHub through their browser without pre-configuring a token. This is useful for:
8+
9+
- **Interactive sessions** where users want to authenticate on-demand
10+
- **Docker deployments** where tokens shouldn't be baked into images
11+
- **Multi-user scenarios** where each user authenticates individually
12+
13+
## Configuration
14+
15+
### Required Environment Variables
16+
17+
| Variable | Description | Required |
18+
|----------|-------------|----------|
19+
| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | Yes |
20+
| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret | Recommended |
21+
22+
### Optional Flags
23+
24+
| Flag | Environment Variable | Description |
25+
|------|---------------------|-------------|
26+
| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed port for OAuth callback (required for Docker with `-p` flag) |
27+
| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Custom OAuth scopes (comma-separated) |
28+
29+
## Authentication Flows
30+
31+
The server automatically selects the appropriate OAuth flow based on the environment:
32+
33+
### 1. PKCE Flow (Browser-based)
34+
35+
Used for local binary execution where a browser can be opened:
36+
37+
1. Server starts a local callback server
38+
2. Browser opens to GitHub authorization page
39+
3. User authorizes the application
40+
4. GitHub redirects to local callback with authorization code
41+
5. Server exchanges code for access token
42+
43+
### 2. Device Flow (Docker/Headless)
44+
45+
Used when running in Docker or when a browser cannot be opened:
46+
47+
1. Server requests a device code from GitHub
48+
2. User is shown a URL and code to enter
49+
3. User visits `github.com/login/device` and enters the code
50+
4. Server polls GitHub until authorization is complete
51+
5. Access token is retrieved
52+
53+
## Usage Examples
54+
55+
### Local Binary
56+
57+
```bash
58+
# Set OAuth credentials
59+
export GITHUB_OAUTH_CLIENT_ID="your-client-id"
60+
export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret"
61+
62+
# Run without PAT - OAuth will trigger when tools are called
63+
./github-mcp-server stdio
64+
```
65+
66+
### Docker (with Device Flow)
67+
68+
```bash
69+
docker run -i --rm \
70+
-e GITHUB_OAUTH_CLIENT_ID="your-client-id" \
71+
-e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \
72+
ghcr.io/github/github-mcp-server stdio
73+
```
74+
75+
### Docker (with PKCE Flow via port mapping)
76+
77+
```bash
78+
docker run -i --rm \
79+
--network=host \
80+
-e GITHUB_OAUTH_CLIENT_ID="your-client-id" \
81+
-e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \
82+
ghcr.io/github/github-mcp-server stdio --oauth-callback-port=8085
83+
```
84+
85+
### VS Code MCP Configuration
86+
87+
```jsonc
88+
{
89+
"mcpServers": {
90+
"github": {
91+
"command": "docker",
92+
"args": [
93+
"run", "-i", "--rm",
94+
"-e", "GITHUB_OAUTH_CLIENT_ID=your-client-id",
95+
"-e", "GITHUB_OAUTH_CLIENT_SECRET=your-client-secret",
96+
"ghcr.io/github/github-mcp-server",
97+
"stdio"
98+
],
99+
"type": "stdio"
100+
}
101+
}
102+
}
103+
```
104+
105+
## Creating an OAuth App
106+
107+
1. Go to **GitHub Settings****Developer settings****OAuth Apps**
108+
2. Click **New OAuth App**
109+
3. Fill in the details:
110+
- **Application name**: Your app name (e.g., "GitHub MCP Server")
111+
- **Homepage URL**: Your homepage or `https://github.com/github/github-mcp-server`
112+
- **Authorization callback URL**: `http://localhost:8085/callback` (or your chosen port)
113+
4. Click **Register application**
114+
5. Copy the **Client ID**
115+
6. Generate and copy the **Client Secret**
116+
117+
## Scope Computation
118+
119+
The server automatically computes the required OAuth scopes based on enabled tools:
120+
121+
- If `--toolsets` or `--tools` are specified, only scopes for those tools are requested
122+
- If no tools are specified, default scopes are used: `repo`, `user`, `gist`, `notifications`, `read:org`, `project`
123+
- Custom scopes can be specified with `--oauth-scopes`
124+
125+
## Security Considerations
126+
127+
1. **Client Secret**: While optional for public OAuth apps, using a client secret is recommended for better security
128+
2. **Token Storage**: OAuth tokens are stored in memory only and not persisted to disk
129+
3. **Scope Minimization**: Request only the scopes needed for your use case
130+
4. **PKCE**: The PKCE flow provides protection against authorization code interception attacks
131+
132+
## Troubleshooting
133+
134+
### "redirect_uri not associated with this client"
135+
136+
Ensure the callback port matches your OAuth app's registered callback URL. Use `--oauth-callback-port` to specify the exact port.
137+
138+
### Browser doesn't open automatically
139+
140+
The server will fall back to displaying the authorization URL. In Docker, the device flow is used automatically.
141+
142+
### Token not being used
143+
144+
Verify that `GITHUB_PERSONAL_ACCESS_TOKEN` is not set, as it takes precedence over OAuth.

internal/oauth/manager.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ func (m *Manager) RequestAuthentication(ctx context.Context, session *mcp.Server
9393
return m.startPKCEFlowWithElicitation(ctx, session)
9494
}
9595

96-
// startDeviceFlowWithElicitation initiates device flow and uses session elicitation
96+
// startDeviceFlowWithElicitation initiates device flow and uses session elicitation.
97+
// Device flow is used when a callback server cannot be started (e.g., in Docker containers).
98+
// It displays a code that the user must enter at the verification URL.
9799
func (m *Manager) startDeviceFlowWithElicitation(ctx context.Context, session *mcp.ServerSession) error {
98100
oauth2Cfg := &oauth2.Config{
99101
ClientID: m.config.ClientID,
@@ -112,11 +114,14 @@ func (m *Manager) startDeviceFlowWithElicitation(ctx context.Context, session *m
112114
return fmt.Errorf("failed to get device authorization: %w", err)
113115
}
114116

115-
// Use session elicitation if available (blocks until user responds)
117+
// Use session elicitation if available to show the user the verification URL and code
116118
if session != nil {
117-
// Generate elicitation ID for tracking
118-
elicitID, _ := generateElicitationID()
119-
// Use the base verification URI and show the code in the message for the user to paste
119+
elicitID, err := generateElicitationID()
120+
if err != nil {
121+
// Log warning but continue - elicitation ID is for tracking only
122+
elicitID = "fallback-id"
123+
}
124+
// Elicitation result is not critical - device flow polls independently
120125
_, _ = session.Elicit(ctx, &mcp.ElicitParams{
121126
Mode: "url",
122127
URL: deviceAuth.VerificationURI,

internal/oauth/oauth_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package oauth
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
@@ -57,6 +58,21 @@ func TestGetGitHubOAuthConfig(t *testing.T) {
5758
assert.Equal(t, "https://github.enterprise.com", cfg.Host)
5859
assert.Equal(t, 8080, cfg.CallbackPort)
5960
})
61+
62+
t.Run("GHEC host (ghe.com)", func(t *testing.T) {
63+
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://mycompany.ghe.com", 0)
64+
65+
assert.Equal(t, "https://mycompany.ghe.com/login/oauth/authorize", cfg.AuthURL)
66+
assert.Equal(t, "https://mycompany.ghe.com/login/oauth/access_token", cfg.TokenURL)
67+
assert.Equal(t, "https://mycompany.ghe.com/login/device/code", cfg.DeviceAuthURL)
68+
})
69+
70+
t.Run("host without scheme", func(t *testing.T) {
71+
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "github.enterprise.com", 0)
72+
73+
// Should default to https
74+
assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL)
75+
})
6076
}
6177

6278
func TestStartLocalServer(t *testing.T) {
@@ -81,3 +97,111 @@ func TestStartLocalServer(t *testing.T) {
8197
assert.Equal(t, fixedPort, port)
8298
})
8399
}
100+
101+
// Manager tests
102+
103+
func TestNewManager(t *testing.T) {
104+
cfg := Config{
105+
ClientID: "test-client-id",
106+
ClientSecret: "test-secret",
107+
Scopes: []string{"repo"},
108+
}
109+
110+
mgr := NewManager(cfg)
111+
112+
assert.NotNil(t, mgr)
113+
assert.Equal(t, cfg.ClientID, mgr.config.ClientID)
114+
assert.Equal(t, cfg.ClientSecret, mgr.config.ClientSecret)
115+
assert.Equal(t, cfg.Scopes, mgr.config.Scopes)
116+
assert.False(t, mgr.HasToken())
117+
assert.Empty(t, mgr.GetAccessToken())
118+
}
119+
120+
func TestManagerHasToken(t *testing.T) {
121+
mgr := NewManager(Config{})
122+
123+
t.Run("no token initially", func(t *testing.T) {
124+
assert.False(t, mgr.HasToken())
125+
})
126+
127+
t.Run("has token after setting", func(t *testing.T) {
128+
mgr.setToken(&Result{
129+
AccessToken: "test-token",
130+
TokenType: "Bearer",
131+
})
132+
133+
assert.True(t, mgr.HasToken())
134+
})
135+
136+
t.Run("no token if empty access token", func(t *testing.T) {
137+
mgr.setToken(&Result{
138+
AccessToken: "",
139+
TokenType: "Bearer",
140+
})
141+
142+
assert.False(t, mgr.HasToken())
143+
})
144+
}
145+
146+
func TestManagerGetAccessToken(t *testing.T) {
147+
mgr := NewManager(Config{})
148+
149+
t.Run("empty initially", func(t *testing.T) {
150+
assert.Empty(t, mgr.GetAccessToken())
151+
})
152+
153+
t.Run("returns token after setting", func(t *testing.T) {
154+
expectedToken := "gho_test123456"
155+
mgr.setToken(&Result{
156+
AccessToken: expectedToken,
157+
TokenType: "Bearer",
158+
RefreshToken: "refresh-token",
159+
Expiry: time.Now().Add(time.Hour),
160+
})
161+
162+
assert.Equal(t, expectedToken, mgr.GetAccessToken())
163+
})
164+
}
165+
166+
func TestManagerSetToken(t *testing.T) {
167+
mgr := NewManager(Config{})
168+
169+
token := &Result{
170+
AccessToken: "test-access-token",
171+
RefreshToken: "test-refresh-token",
172+
TokenType: "Bearer",
173+
Expiry: time.Now().Add(time.Hour),
174+
}
175+
176+
mgr.setToken(token)
177+
178+
// Verify token is stored correctly
179+
assert.Equal(t, token.AccessToken, mgr.GetAccessToken())
180+
assert.True(t, mgr.HasToken())
181+
}
182+
183+
func TestGenerateState(t *testing.T) {
184+
state1, err := generateState()
185+
require.NoError(t, err)
186+
require.NotEmpty(t, state1)
187+
188+
// State should be URL-safe base64 encoded
189+
// 16 bytes of random data = ~22 chars in base64url
190+
assert.GreaterOrEqual(t, len(state1), 20)
191+
192+
// Each call should produce unique state
193+
state2, err := generateState()
194+
require.NoError(t, err)
195+
assert.NotEqual(t, state1, state2)
196+
}
197+
198+
func TestGenerateElicitationID(t *testing.T) {
199+
id1, err := generateElicitationID()
200+
require.NoError(t, err)
201+
require.NotEmpty(t, id1)
202+
203+
// Each call should produce unique ID
204+
id2, err := generateElicitationID()
205+
require.NoError(t, err)
206+
assert.NotEqual(t, id1, id2)
207+
}

0 commit comments

Comments
 (0)