Skip to content

Commit 91d4372

Browse files
feat: add OAuth 2.1 authentication for stdio mode
Add PKCE and device flow OAuth support for stdio mode, enabling browser-based authentication as an alternative to PATs. Flow priority (security-ordered): 1. PKCE + browser auto-open (native) 2. PKCE + URL elicitation (Docker with bound port) 3. Device flow fallback (more phishable, last resort) Key changes: - internal/oauth: self-contained OAuth manager with PKCE and device flow - internal/buildinfo: build-time OAuth credential injection via ldflags - BearerAuthTransport: added TokenProvider for dynamic token resolution - OAuth middleware intercepts tools/call to trigger lazy authentication - Scope-based tool filtering using existing SupportedScopes - PAT remains optional when OAuth credentials are configured Security: - PKCE S256 prevents code interception - State parameter prevents CSRF - Callback binds to 127.0.0.1 only - URL elicitation for sensitive URLs (never exposed to LLM) - Tokens stored in memory only, never persisted to disk - ReadHeaderTimeout prevents Slowloris on callback server - html/template auto-escaping prevents XSS in callback pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 486e9fe commit 91d4372

File tree

16 files changed

+1343
-12
lines changed

16 files changed

+1343
-12
lines changed

.github/workflows/docker-publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ jobs:
117117
platforms: linux/amd64,linux/arm64
118118
build-args: |
119119
VERSION=${{ github.ref_name }}
120+
OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }}
121+
OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }}
120122
121123
# Sign the resulting Docker image digest except on PRs.
122124
# This will only write to the public Rekor transparency log when the Docker

.github/workflows/goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ jobs:
4545
workdir: .
4646
env:
4747
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
49+
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
4850

4951
- name: Generate signed build provenance attestations for workflow artifacts
5052
uses: actions/attest-build-provenance@v3

.goreleaser.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ builds:
99
- env:
1010
- CGO_ENABLED=0
1111
ldflags:
12-
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
12+
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }}
1313
goos:
1414
- linux
1515
- windows

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ RUN mkdir -p ./pkg/github/ui_dist && \
99

1010
FROM golang:1.25.7-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS build
1111
ARG VERSION="dev"
12+
ARG OAUTH_CLIENT_ID=""
13+
ARG OAUTH_CLIENT_SECRET=""
1214

1315
# Set the working directory
1416
WORKDIR /build
@@ -26,7 +28,7 @@ COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/
2628
# Build the server
2729
RUN --mount=type=cache,target=/go/pkg/mod \
2830
--mount=type=cache,target=/root/.cache/go-build \
29-
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
31+
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \
3032
-o /bin/github-mcp-server ./cmd/github-mcp-server
3133

3234
# Make a stage to run the app

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,39 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
239239

240240
</details>
241241

242+
### OAuth Authentication (stdio mode)
243+
244+
For stdio mode, you can use OAuth 2.1 instead of a Personal Access Token. The server triggers the OAuth flow on the first tool call:
245+
246+
| Environment | Flow | Setup |
247+
|-------------|------|-------|
248+
| Docker with port | PKCE (URL elicitation) | Set `GITHUB_OAUTH_CLIENT_ID` + bind port |
249+
| Docker without port | Device flow (enter code at github.com/login/device) | Set `GITHUB_OAUTH_CLIENT_ID` |
250+
| Native binary | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CLIENT_ID` |
251+
252+
**Docker example (PKCE with bound port — recommended):**
253+
```json
254+
{
255+
"mcpServers": {
256+
"github": {
257+
"command": "docker",
258+
"args": ["run", "-i", "--rm",
259+
"-e", "GITHUB_OAUTH_CLIENT_ID",
260+
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
261+
"-e", "GITHUB_OAUTH_CALLBACK_PORT=8085",
262+
"-p", "127.0.0.1:8085:8085",
263+
"ghcr.io/github/github-mcp-server"],
264+
"env": {
265+
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
266+
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
267+
}
268+
}
269+
}
270+
}
271+
```
272+
273+
See [docs/oauth-authentication.md](docs/oauth-authentication.md) for full setup instructions, including how to create a GitHub OAuth App.
274+
242275
### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)
243276

244277
The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set

cmd/github-mcp-server/main.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import (
77
"strings"
88
"time"
99

10+
"github.com/github/github-mcp-server/internal/buildinfo"
1011
"github.com/github/github-mcp-server/internal/ghmcp"
12+
"github.com/github/github-mcp-server/internal/oauth"
1113
"github.com/github/github-mcp-server/pkg/github"
1214
ghhttp "github.com/github/github-mcp-server/pkg/http"
15+
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
1316
"github.com/spf13/cobra"
1417
"github.com/spf13/pflag"
1518
"github.com/spf13/viper"
@@ -34,8 +37,12 @@ var (
3437
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3538
RunE: func(_ *cobra.Command, _ []string) error {
3639
token := viper.GetString("personal_access_token")
37-
if token == "" {
38-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
40+
41+
// Resolve OAuth credentials: explicit config > build-time > none
42+
oauthClientID, oauthClientSecret := resolveOAuthCredentials()
43+
44+
if token == "" && oauthClientID == "" {
45+
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set and no OAuth credentials available")
3946
}
4047

4148
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -96,6 +103,22 @@ var (
96103
ExcludeTools: excludeTools,
97104
RepoAccessCacheTTL: &ttl,
98105
}
106+
107+
// Configure OAuth if credentials are available and no PAT is set.
108+
// PAT takes priority — if both are configured, PAT is used directly.
109+
if token == "" && oauthClientID != "" {
110+
oauthScopes := getOAuthScopes(stdioServerConfig)
111+
oauthCfg := oauth.GetGitHubOAuthConfig(
112+
oauthClientID,
113+
oauthClientSecret,
114+
oauthScopes,
115+
viper.GetString("host"),
116+
viper.GetInt("oauth-callback-port"),
117+
)
118+
stdioServerConfig.OAuthManager = oauth.NewManager(oauthCfg, nil)
119+
stdioServerConfig.OAuthScopes = oauthScopes
120+
}
121+
99122
return ghmcp.RunStdioServer(stdioServerConfig)
100123
},
101124
}
@@ -154,6 +177,12 @@ func init() {
154177
httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
155178
httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses")
156179

180+
// OAuth flags (stdio only)
181+
stdioCmd.Flags().String("oauth-client-id", "", "OAuth client ID for browser-based authentication")
182+
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret")
183+
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Explicit OAuth scopes to request (overrides automatic computation)")
184+
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback server (0 for random, required for Docker with -p)")
185+
157186
// Bind flag to viper
158187
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
159188
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
@@ -173,6 +202,10 @@ func init() {
173202
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
174203
_ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))
175204
_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
205+
_ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
206+
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
207+
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
208+
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
176209
// Add subcommands
177210
rootCmd.AddCommand(stdioCmd)
178211
rootCmd.AddCommand(httpCmd)
@@ -200,3 +233,39 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
200233
}
201234
return pflag.NormalizedName(name)
202235
}
236+
237+
// resolveOAuthCredentials returns OAuth client credentials from the best
238+
// available source. Priority: explicit config > build-time baked > none.
239+
func resolveOAuthCredentials() (clientID, clientSecret string) {
240+
clientID = viper.GetString("oauth-client-id")
241+
clientSecret = viper.GetString("oauth-client-secret")
242+
if clientID != "" {
243+
return clientID, clientSecret
244+
}
245+
246+
if buildinfo.OAuthClientID != "" {
247+
return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret
248+
}
249+
250+
return "", ""
251+
}
252+
253+
// getOAuthScopes returns the OAuth scopes to request. Uses explicit override
254+
// if provided, otherwise falls back to the canonical SupportedScopes list
255+
// which covers all tools the server may expose.
256+
func getOAuthScopes(cfg ghmcp.StdioServerConfig) []string {
257+
_ = cfg // reserved for future per-toolset scope computation
258+
259+
if viper.IsSet("oauth-scopes") {
260+
var scopes []string
261+
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err == nil && len(scopes) > 0 {
262+
return scopes
263+
}
264+
}
265+
266+
// Use the canonical list maintained alongside the HTTP OAuth metadata.
267+
// This requests all scopes any tool might need. The consent screen shows
268+
// the user exactly what is being requested, and scope-based tool filtering
269+
// hides tools the granted token cannot satisfy.
270+
return ghoauth.SupportedScopes
271+
}

docs/oauth-authentication.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# OAuth Authentication (stdio mode)
2+
3+
The GitHub MCP Server supports OAuth 2.1 authentication for stdio mode, allowing users to authenticate via their browser instead of manually creating Personal Access Tokens.
4+
5+
## How It Works
6+
7+
When no `GITHUB_PERSONAL_ACCESS_TOKEN` is configured and OAuth credentials are available, the server starts without a token. On the first tool call, it triggers the OAuth flow:
8+
9+
1. **PKCE flow** (primary): A local callback server starts, your browser opens to GitHub's authorization page, and the token is received via callback. If the browser cannot open (e.g., Docker), the authorization URL is shown via [MCP URL elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation).
10+
11+
2. **Device flow** (fallback): If the callback server cannot start (e.g., Docker without port binding), the server falls back to GitHub's [device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). A code is displayed that you enter at [github.com/login/device](https://github.com/login/device).
12+
13+
### Authentication Priority
14+
15+
| Priority | Source | Notes |
16+
|----------|--------|-------|
17+
| 1 (highest) | `GITHUB_PERSONAL_ACCESS_TOKEN` | PAT is used directly, OAuth is skipped |
18+
| 2 | `GITHUB_OAUTH_CLIENT_ID` (env/flag) | Explicit OAuth credentials |
19+
| 3 | Built-in credentials | Baked into official releases via build flags |
20+
21+
## Docker Setup (Recommended)
22+
23+
Docker is the standard distribution method. The recommended setup uses PKCE with a bound port:
24+
25+
```json
26+
{
27+
"mcpServers": {
28+
"github": {
29+
"command": "docker",
30+
"args": [
31+
"run", "-i", "--rm",
32+
"-e", "GITHUB_OAUTH_CLIENT_ID",
33+
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
34+
"-e", "GITHUB_OAUTH_CALLBACK_PORT=8085",
35+
"-p", "127.0.0.1:8085:8085",
36+
"ghcr.io/github/github-mcp-server"
37+
],
38+
"env": {
39+
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
40+
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
41+
}
42+
}
43+
}
44+
}
45+
```
46+
47+
> **Security**: Always bind to `127.0.0.1` (not `0.0.0.0`) to restrict the callback to localhost.
48+
49+
### Docker Without Port Binding (Device Flow)
50+
51+
If you cannot bind a port, the server falls back to device flow:
52+
53+
```json
54+
{
55+
"mcpServers": {
56+
"github": {
57+
"command": "docker",
58+
"args": [
59+
"run", "-i", "--rm",
60+
"-e", "GITHUB_OAUTH_CLIENT_ID",
61+
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
62+
"ghcr.io/github/github-mcp-server"
63+
],
64+
"env": {
65+
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
66+
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
67+
}
68+
}
69+
}
70+
}
71+
```
72+
73+
## Native Binary Setup
74+
75+
For native binaries, PKCE works automatically with a random port:
76+
77+
```bash
78+
export GITHUB_OAUTH_CLIENT_ID="your_client_id"
79+
export GITHUB_OAUTH_CLIENT_SECRET="your_client_secret"
80+
./github-mcp-server stdio
81+
```
82+
83+
The browser opens automatically. No port configuration needed.
84+
85+
## Creating a GitHub OAuth App
86+
87+
1. Go to **GitHub Settings****Developer settings****OAuth Apps**
88+
2. Click **New OAuth App**
89+
3. Fill in:
90+
- **Application name**: e.g., "GitHub MCP Server"
91+
- **Homepage URL**: `https://github.com/github/github-mcp-server`
92+
- **Authorization callback URL**: `http://localhost:8085/callback` (match your `--oauth-callback-port`)
93+
4. Click **Register application**
94+
5. Copy the **Client ID** and generate a **Client Secret**
95+
96+
> **Note**: The callback URL must be registered even for device flow, though it won't be used.
97+
98+
## Configuration Reference
99+
100+
| Environment Variable | Flag | Description |
101+
|---------------------|------|-------------|
102+
| `GITHUB_OAUTH_CLIENT_ID` | `--oauth-client-id` | OAuth client ID |
103+
| `GITHUB_OAUTH_CLIENT_SECRET` | `--oauth-client-secret` | OAuth client secret |
104+
| `GITHUB_OAUTH_CALLBACK_PORT` | `--oauth-callback-port` | Fixed callback port (0 = random) |
105+
| `GITHUB_OAUTH_SCOPES` | `--oauth-scopes` | Override automatic scope selection |
106+
107+
## Security Design
108+
109+
### PKCE (Proof Key for Code Exchange)
110+
All authorization code flows use PKCE with S256 challenge, preventing authorization code interception even if an attacker can observe the callback.
111+
112+
### Fixed Port Considerations
113+
Docker requires a fixed callback port for port mapping. This is acceptable because:
114+
- **PKCE verifier** is generated per-flow and never leaves the process — an attacker who intercepts the callback cannot exchange the code
115+
- **State parameter** prevents CSRF — the callback validates state match
116+
- **Callback server binds to 127.0.0.1** — not accessible from outside the host
117+
- **Short-lived** — the server shuts down immediately after receiving the callback
118+
119+
### Token Handling
120+
- Tokens are stored **in memory only** — never written to disk
121+
- OAuth token takes precedence over PAT if both become available
122+
- The server requests only the scopes needed by the configured tools
123+
124+
### URL Elicitation Security
125+
When the browser cannot auto-open, the authorization URL is shown via MCP URL-mode elicitation. This is secure because:
126+
- URL elicitation presents the URL to the user without exposing it to the LLM context
127+
- The MCP client shows the full URL for user inspection before navigation
128+
- Credentials flow directly between the user's browser and GitHub — never through the MCP channel
129+
130+
### Device Flow as Fallback
131+
Device flow is more susceptible to social engineering than PKCE (the device code could theoretically be phished), which is why PKCE is always attempted first. Device flow is only used when a callback server cannot be started.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/spf13/viper v1.21.0
2020
github.com/stretchr/testify v1.11.1
2121
github.com/yosida95/uritemplate/v3 v3.0.2
22+
golang.org/x/oauth2 v0.34.0
2223
)
2324

2425
require (
@@ -45,7 +46,6 @@ require (
4546
go.yaml.in/yaml/v3 v3.0.4 // indirect
4647
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
4748
golang.org/x/net v0.38.0 // indirect
48-
golang.org/x/oauth2 v0.34.0 // indirect
4949
golang.org/x/sys v0.40.0 // indirect
5050
golang.org/x/text v0.28.0 // indirect
5151
gopkg.in/yaml.v3 v3.0.1 // indirect

internal/buildinfo/buildinfo.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Package buildinfo contains variables that are set at build time via ldflags.
2+
// These allow official releases to include default OAuth credentials without
3+
// requiring end-user configuration.
4+
//
5+
// Example ldflags usage:
6+
//
7+
// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx"
8+
package buildinfo
9+
10+
// OAuthClientID is the default OAuth client ID, set at build time.
11+
var OAuthClientID string
12+
13+
// OAuthClientSecret is the default OAuth client secret, set at build time.
14+
// Note: For public OAuth clients (native apps), the client secret is not
15+
// truly secret per OAuth 2.1 — security relies on PKCE, not the secret.
16+
var OAuthClientSecret string

0 commit comments

Comments
 (0)