Skip to content

Commit 7b5c1fe

Browse files
Add device flow fallback for Docker environments
- Add StartDeviceFlow for environments without callback capabilities - Add StartOAuthFlow that auto-selects between device and interactive flows - Detect Docker environment and use device flow automatically - Add --oauth-callback-port flag for advanced Docker users with port binding - Support fixed ports in callback server for Docker -p usage - Update all OAuth endpoints to include device auth URL - Comprehensive tests for both flows and port configurations - Update README with Docker-specific OAuth instructions Co-authored-by: SamMorrowDrums <[email protected]>
1 parent fc957af commit 7b5c1fe

File tree

4 files changed

+171
-36
lines changed

4 files changed

+171
-36
lines changed

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,17 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
190190

191191
For stdio mode (local binary execution), you can use OAuth 2.1 with PKCE instead of a Personal Access Token. This provides an interactive browser-based login flow.
192192

193+
**The OAuth flow automatically adapts to your environment:**
194+
- **Native binary**: Uses interactive PKCE flow (browser opens automatically)
195+
- **Docker container**: Uses device flow (displays code + URL, no callback needed)
196+
- **Docker with port binding**: Can use PKCE flow with `--oauth-callback-port`
197+
193198
#### Prerequisites for OAuth
194199

195200
1. Create a GitHub OAuth App at [https://github.com/settings/developers](https://github.com/settings/developers)
196-
- Set the callback URL to `http://localhost` (the actual port will be dynamically assigned)
201+
- For native binary: Set callback URL to `http://localhost` (port is dynamic)
202+
- For Docker with port binding: Set callback URL to `http://localhost:PORT/callback` (your chosen port)
203+
- For Docker with device flow: No callback URL needed
197204
- For public clients, you can use PKCE without a client secret
198205

199206
2. Set your OAuth app credentials:
@@ -204,22 +211,47 @@ For stdio mode (local binary execution), you can use OAuth 2.1 with PKCE instead
204211

205212
3. Run the server without a PAT:
206213
```bash
214+
# Native binary - interactive PKCE flow
207215
./github-mcp-server stdio
216+
217+
# Docker - device flow (automatic)
218+
docker run -i --rm -e GITHUB_OAUTH_CLIENT_ID=your_client_id ghcr.io/github/github-mcp-server
219+
220+
# Docker with port binding - interactive PKCE flow
221+
docker run -i --rm -p 8080:8080 \
222+
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
223+
-e GITHUB_OAUTH_CALLBACK_PORT=8080 \
224+
ghcr.io/github/github-mcp-server
208225
```
209226

210-
The server will automatically detect the OAuth configuration and launch your browser for authorization. After you approve, the server will receive the token and start normally.
227+
The server will automatically detect the environment and use the appropriate OAuth flow.
211228

212229
#### OAuth Configuration Options
213230

214231
- `--oauth-client-id` / `GITHUB_OAUTH_CLIENT_ID` - Your GitHub OAuth app client ID (required for OAuth flow)
215232
- `--oauth-client-secret` / `GITHUB_OAUTH_CLIENT_SECRET` - Your client secret (optional, PKCE is used)
216233
- `--oauth-scopes` / `GITHUB_OAUTH_SCOPES` - Comma-separated list of scopes (defaults: `repo,user,gist,notifications,read:org,project`)
234+
- `--oauth-callback-port` / `GITHUB_OAUTH_CALLBACK_PORT` - Fixed callback port for Docker (0 for random)
217235

218236
Example with custom scopes:
219237
```bash
220238
./github-mcp-server stdio --oauth-client-id YOUR_CLIENT_ID --oauth-scopes repo,user
221239
```
222240

241+
#### Device Flow vs Interactive Flow
242+
243+
**Device Flow** (automatic in Docker):
244+
- Displays a verification URL and code
245+
- User visits URL in browser and enters code
246+
- No callback server required
247+
- Works in any environment
248+
249+
**Interactive PKCE Flow** (automatic for native binary):
250+
- Opens browser automatically
251+
- User approves scopes
252+
- Faster and more seamless
253+
- Requires callback server (localhost)
254+
223255
> **Note**: OAuth authentication is only available in stdio mode. For remote server usage, use Personal Access Tokens as described above.
224256
225257
### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)

cmd/github-mcp-server/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ var (
4545
viper.GetString("oauth_client_secret"),
4646
getOAuthScopes(),
4747
viper.GetString("host"), // Pass the gh-host configuration
48+
viper.GetInt("oauth_callback_port"),
4849
)
4950

50-
result, err := oauth.StartInteractiveFlow(cmd.Context(), oauthCfg)
51+
result, err := oauth.StartOAuthFlow(cmd.Context(), oauthCfg)
5152
if err != nil {
5253
return fmt.Errorf("OAuth flow failed: %w", err)
5354
}
@@ -137,6 +138,7 @@ func init() {
137138
rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)")
138139
rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (optional for public clients with PKCE)")
139140
rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)")
141+
rootCmd.PersistentFlags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback (0 for random, required for Docker with -p flag)")
140142

141143
// Bind flag to viper
142144
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -155,6 +157,7 @@ func init() {
155157
_ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id"))
156158
_ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret"))
157159
_ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes"))
160+
_ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port"))
158161

159162
// Add subcommands
160163
rootCmd.AddCommand(stdioCmd)

internal/oauth/oauth.go

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ const (
2525

2626
// Config holds the OAuth configuration
2727
type Config struct {
28-
ClientID string
29-
ClientSecret string // Optional for public clients with PKCE
30-
RedirectURL string
31-
Scopes []string
32-
AuthURL string
33-
TokenURL string
34-
Host string // GitHub host (for constructing OAuth URLs)
28+
ClientID string
29+
ClientSecret string // Optional for public clients with PKCE
30+
RedirectURL string
31+
Scopes []string
32+
AuthURL string
33+
TokenURL string
34+
Host string // GitHub host (for constructing OAuth URLs)
35+
DeviceAuthURL string // Device authorization URL (for device flow)
36+
CallbackPort int // Fixed callback port (0 for random)
3537
}
3638

3739
// Result contains the OAuth flow result
@@ -54,6 +56,81 @@ func generatePKCEVerifier() (string, error) {
5456
return verifier, nil
5557
}
5658

59+
// isRunningInDocker detects if the process is running inside a Docker container
60+
func isRunningInDocker() bool {
61+
// Check for .dockerenv file (most common indicator)
62+
if _, err := os.Stat("/.dockerenv"); err == nil {
63+
return true
64+
}
65+
66+
// Check cgroup for docker (fallback)
67+
data, err := os.ReadFile("/proc/1/cgroup")
68+
if err == nil && (strings.Contains(string(data), "docker") || strings.Contains(string(data), "containerd")) {
69+
return true
70+
}
71+
72+
return false
73+
}
74+
75+
// StartDeviceFlow initiates an OAuth device authorization flow
76+
// This is suitable for environments without callback capabilities (like Docker containers)
77+
func StartDeviceFlow(ctx context.Context, cfg Config) (*Result, error) {
78+
oauth2Cfg := &oauth2.Config{
79+
ClientID: cfg.ClientID,
80+
ClientSecret: cfg.ClientSecret,
81+
Scopes: cfg.Scopes,
82+
Endpoint: oauth2.Endpoint{
83+
AuthURL: cfg.AuthURL,
84+
TokenURL: cfg.TokenURL,
85+
DeviceAuthURL: cfg.DeviceAuthURL,
86+
},
87+
}
88+
89+
// Request device authorization
90+
deviceAuth, err := oauth2Cfg.DeviceAuth(ctx)
91+
if err != nil {
92+
return nil, fmt.Errorf("failed to get device authorization: %w", err)
93+
}
94+
95+
// Display verification instructions to user
96+
fmt.Fprint(os.Stderr, "\n"+strings.Repeat("=", 80)+"\n")
97+
fmt.Fprint(os.Stderr, "GitHub OAuth Device Authorization\n")
98+
fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n")
99+
fmt.Fprintf(os.Stderr, "Please visit: %s\n\n", deviceAuth.VerificationURI)
100+
fmt.Fprintf(os.Stderr, "And enter code: %s\n\n", deviceAuth.UserCode)
101+
fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n")
102+
103+
// Poll for token
104+
token, err := oauth2Cfg.DeviceAccessToken(ctx, deviceAuth)
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to get device access token: %w", err)
107+
}
108+
109+
fmt.Fprint(os.Stderr, "\n✓ Authorization successful!\n\n")
110+
111+
return &Result{
112+
AccessToken: token.AccessToken,
113+
RefreshToken: token.RefreshToken,
114+
TokenType: token.TokenType,
115+
Expiry: token.Expiry,
116+
}, nil
117+
}
118+
119+
// StartOAuthFlow automatically selects the appropriate OAuth flow based on environment
120+
// - Device flow for Docker containers (no callback server possible)
121+
// - Interactive PKCE flow for native binaries (best UX with browser)
122+
func StartOAuthFlow(ctx context.Context, cfg Config) (*Result, error) {
123+
// Check if we're in Docker
124+
if isRunningInDocker() && cfg.CallbackPort == 0 {
125+
// Docker without explicit callback port - use device flow
126+
log.Printf("Detected Docker environment, using device flow")
127+
return StartDeviceFlow(ctx, cfg)
128+
}
129+
130+
// Use interactive PKCE flow (browser-based)
131+
return StartInteractiveFlow(ctx, cfg)
132+
}
133+
57134
// StartInteractiveFlow initiates an interactive OAuth flow with PKCE
58135
// This is intended for stdio mode only and opens a browser for user consent
59136
func StartInteractiveFlow(ctx context.Context, cfg Config) (*Result, error) {
@@ -83,7 +160,7 @@ func StartInteractiveFlow(ctx context.Context, cfg Config) (*Result, error) {
83160
state := base64.RawURLEncoding.EncodeToString(stateBytes)
84161

85162
// Start local HTTP server for callback
86-
listener, port, err := startLocalServer()
163+
listener, port, err := startLocalServer(cfg.CallbackPort)
87164
if err != nil {
88165
return nil, fmt.Errorf("failed to start local server: %w", err)
89166
}
@@ -167,15 +244,17 @@ func StartInteractiveFlow(ctx context.Context, cfg Config) (*Result, error) {
167244
}, nil
168245
}
169246

170-
// startLocalServer starts a local HTTP server on a random available port
171-
func startLocalServer() (net.Listener, int, error) {
172-
listener, err := net.Listen("tcp", "localhost:0")
247+
// startLocalServer starts a local HTTP server on the specified port
248+
// If port is 0, uses a random available port
249+
func startLocalServer(port int) (net.Listener, int, error) {
250+
addr := fmt.Sprintf("localhost:%d", port)
251+
listener, err := net.Listen("tcp", addr)
173252
if err != nil {
174-
return nil, 0, fmt.Errorf("failed to start listener: %w", err)
253+
return nil, 0, fmt.Errorf("failed to start listener on %s: %w", addr, err)
175254
}
176255

177-
port := listener.Addr().(*net.TCPAddr).Port
178-
return listener, port, nil
256+
actualPort := listener.Addr().(*net.TCPAddr).Port
257+
return listener, actualPort, nil
179258
}
180259

181260
// createCallbackHandler creates an HTTP handler for the OAuth callback
@@ -263,25 +342,28 @@ func openBrowser(url string) error {
263342

264343
// GetGitHubOAuthConfig returns the GitHub OAuth configuration for the specified host
265344
// host can be empty for github.com, or a full URL like "https://github.enterprise.com" for GHES
266-
func GetGitHubOAuthConfig(clientID, clientSecret string, scopes []string, host string) Config {
267-
authURL, tokenURL := getOAuthEndpoints(host)
345+
func GetGitHubOAuthConfig(clientID, clientSecret string, scopes []string, host string, callbackPort int) Config {
346+
authURL, tokenURL, deviceAuthURL := getOAuthEndpoints(host)
268347

269348
return Config{
270-
ClientID: clientID,
271-
ClientSecret: clientSecret,
272-
Scopes: scopes,
273-
AuthURL: authURL,
274-
TokenURL: tokenURL,
275-
Host: host,
349+
ClientID: clientID,
350+
ClientSecret: clientSecret,
351+
Scopes: scopes,
352+
AuthURL: authURL,
353+
TokenURL: tokenURL,
354+
DeviceAuthURL: deviceAuthURL,
355+
Host: host,
356+
CallbackPort: callbackPort,
276357
}
277358
}
278359

279360
// getOAuthEndpoints returns the appropriate OAuth endpoints based on the host
280-
func getOAuthEndpoints(host string) (authURL, tokenURL string) {
361+
func getOAuthEndpoints(host string) (authURL, tokenURL, deviceAuthURL string) {
281362
// Default to github.com
282363
if host == "" {
283364
return "https://github.com/login/oauth/authorize",
284-
"https://github.com/login/oauth/access_token"
365+
"https://github.com/login/oauth/access_token",
366+
"https://github.com/login/device/code"
285367
}
286368

287369
// For GHES/GHEC, OAuth endpoints are at the main domain, not api subdomain
@@ -319,6 +401,7 @@ func getOAuthEndpoints(host string) (authURL, tokenURL string) {
319401

320402
authURL = fmt.Sprintf("%s://%s/login/oauth/authorize", scheme, hostname)
321403
tokenURL = fmt.Sprintf("%s://%s/login/oauth/access_token", scheme, hostname)
404+
deviceAuthURL = fmt.Sprintf("%s://%s/login/device/code", scheme, hostname)
322405

323-
return authURL, tokenURL
406+
return authURL, tokenURL, deviceAuthURL
324407
}

internal/oauth/oauth_test.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,34 +33,51 @@ func TestGetGitHubOAuthConfig(t *testing.T) {
3333
scopes := []string{"repo", "user"}
3434

3535
t.Run("default github.com", func(t *testing.T) {
36-
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "")
36+
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "", 0)
3737

3838
assert.Equal(t, clientID, cfg.ClientID)
3939
assert.Equal(t, clientSecret, cfg.ClientSecret)
4040
assert.Equal(t, scopes, cfg.Scopes)
4141
assert.Equal(t, "https://github.com/login/oauth/authorize", cfg.AuthURL)
4242
assert.Equal(t, "https://github.com/login/oauth/access_token", cfg.TokenURL)
43+
assert.Equal(t, "https://github.com/login/device/code", cfg.DeviceAuthURL)
4344
assert.Equal(t, "", cfg.Host)
45+
assert.Equal(t, 0, cfg.CallbackPort)
4446
})
4547

4648
t.Run("GHES host", func(t *testing.T) {
47-
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://github.enterprise.com")
49+
cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://github.enterprise.com", 8080)
4850

4951
assert.Equal(t, clientID, cfg.ClientID)
5052
assert.Equal(t, clientSecret, cfg.ClientSecret)
5153
assert.Equal(t, scopes, cfg.Scopes)
5254
assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL)
5355
assert.Equal(t, "https://github.enterprise.com/login/oauth/access_token", cfg.TokenURL)
56+
assert.Equal(t, "https://github.enterprise.com/login/device/code", cfg.DeviceAuthURL)
5457
assert.Equal(t, "https://github.enterprise.com", cfg.Host)
58+
assert.Equal(t, 8080, cfg.CallbackPort)
5559
})
5660
}
5761

5862
func TestStartLocalServer(t *testing.T) {
59-
listener, port, err := startLocalServer()
60-
require.NoError(t, err)
61-
require.NotNil(t, listener)
62-
defer listener.Close()
63+
t.Run("random port", func(t *testing.T) {
64+
listener, port, err := startLocalServer(0)
65+
require.NoError(t, err)
66+
require.NotNil(t, listener)
67+
defer listener.Close()
68+
69+
assert.Greater(t, port, 0)
70+
assert.Less(t, port, 65536)
71+
})
6372

64-
assert.Greater(t, port, 0)
65-
assert.Less(t, port, 65536)
73+
t.Run("fixed port", func(t *testing.T) {
74+
// Use a high port to avoid conflicts
75+
fixedPort := 54321
76+
listener, port, err := startLocalServer(fixedPort)
77+
require.NoError(t, err)
78+
require.NotNil(t, listener)
79+
defer listener.Close()
80+
81+
assert.Equal(t, fixedPort, port)
82+
})
6683
}

0 commit comments

Comments
 (0)