diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 43eca9fad..ca234991e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,11 +16,21 @@ on: branches: ["main", "next"] workflow_dispatch: inputs: - description: - required: false - description: "Description of the run." - type: string - default: "Manual run" + ref: + description: "Git ref to build (branch, tag, or SHA). Leave empty for default branch." + required: false + type: string + default: "" + tag: + description: "Custom image tag (e.g., 'test-oauth', 'pr-1836'). Required for manual runs." + required: false + type: string + default: "" + push: + description: "Push to registry" + required: false + type: boolean + default: true env: # Use docker.io for Docker Hub if empty @@ -41,6 +51,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer @@ -76,15 +88,17 @@ jobs: tags: | type=schedule type=ref,event=branch - type=ref,event=tag + type=ref,event=tag,enable=${{ github.event_name != 'workflow_dispatch' }} type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{version}},enable=${{ github.event_name != 'workflow_dispatch' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name != 'workflow_dispatch' }} + type=semver,pattern={{major}},enable=${{ github.event_name != 'workflow_dispatch' }} type=sha - type=edge + type=edge,enable=${{ github.event_name != 'workflow_dispatch' }} + # Custom tag for manual workflow dispatch + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} # Custom rule to prevent pre-releases from getting latest tag - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} + type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - name: Go Build Cache for Docker uses: actions/cache@v5 @@ -104,14 +118,16 @@ jobs: uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.push) }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 build-args: | - VERSION=${{ github.ref_name }} + VERSION=${{ inputs.ref || github.ref_name }} + OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 167760cba..2984d0744 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,6 +35,8 @@ jobs: workdir: . env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts uses: actions/attest-build-provenance@v3 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 54f6b9f40..36dfc47bc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - -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 }} goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index 92ed52581..b6bdcbf08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM golang:1.25.4-alpine AS build ARG VERSION="dev" +ARG OAUTH_CLIENT_ID="" +ARG OAUTH_CLIENT_SECRET="" # Set the working directory WORKDIR /build @@ -13,7 +15,7 @@ RUN --mount=type=cache,target=/var/cache/apk \ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,target=. \ - 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)" \ + 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}" \ -o /bin/github-mcp-server cmd/github-mcp-server/main.go # Make a stage to run the app diff --git a/README.md b/README.md index 64b68a37a..32b6b09af 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,37 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: +### OAuth Authentication (stdio mode) + +For stdio mode, you can use OAuth 2.1 instead of a Personal Access Token. The server automatically selects the appropriate flow: + +| Environment | Flow | Setup | +|-------------|------|-------| +| Native binary | PKCE (browser auto-opens) | Just set `GITHUB_OAUTH_CLIENT_ID` | +| Docker | Device flow (enter code at github.com/login/device) | Just set `GITHUB_OAUTH_CLIENT_ID` | +| Docker with port | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CALLBACK_PORT` and bind port | + +**Example MCP configuration (Docker with device flow):** +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "-e", "GITHUB_OAUTH_CLIENT_SECRET", + "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_CLIENT_ID": "your_client_id", + "GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret" + } + } + } +} +``` + +See [docs/oauth-authentication.md](docs/oauth-authentication.md) for full setup instructions, including how to create a GitHub OAuth App. + ### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index d8b8bf392..6a061219c 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -101,27 +101,28 @@ func runListScopes() error { } } + // Get enabled features (similar to toolsets) + var enabledFeatures []string + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } + } + readOnly := viper.GetBool("read-only") outputFormat := viper.GetString("list-scopes-output") // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory using the same logic as the stdio server - inventoryBuilder := github.NewInventory(t). - WithReadOnly(readOnly) - - // Configure toolsets (same as stdio) - if enabledToolsets != nil { - inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) - } - - // Configure specific tools - if len(enabledTools) > 0 { - inventoryBuilder = inventoryBuilder.WithTools(enabledTools) - } - - inv, err := inventoryBuilder.Build() + // Build inventory using the shared builder for consistency + inv, err := github.NewStandardBuilder(github.InventoryConfig{ + Translator: t, + ReadOnly: readOnly, + Toolsets: enabledToolsets, + Tools: enabledTools, + EnabledFeatures: enabledFeatures, + }).Build() if err != nil { return fmt.Errorf("failed to build inventory: %w", err) } diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index af59ee0e6..1c8688c5f 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,14 +1,19 @@ package main import ( - "errors" + "context" "fmt" "os" + "sort" "strings" "time" + "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -32,11 +37,6 @@ var ( Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { - token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") - } - // If you're wondering why we're not using viper.GetStringSlice("toolsets"), // it's because viper doesn't handle comma-separated values correctly for env // vars when using GetStringSlice. @@ -68,11 +68,49 @@ var ( } } + token := viper.GetString("personal_access_token") + var oauthMgr *oauth.Manager + var oauthScopes []string + var prebuiltInventory *inventory.Inventory + + // If no token provided, setup OAuth manager + // Priority: 1. Explicit OAuth config, 2. Build-time credentials, 3. None + if token == "" { + oauthClientID, oauthClientSecret := resolveOAuthCredentials() + if oauthClientID != "" { + // Get translation helper for inventory building + t, _ := translations.TranslationHelper() + + // Compute OAuth scopes and get inventory (avoids double building) + scopesResult := getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures, t) + oauthScopes = scopesResult.scopes + prebuiltInventory = scopesResult.inventory + + // Create OAuth manager for lazy authentication + oauthCfg := oauth.GetGitHubOAuthConfig( + oauthClientID, + oauthClientSecret, + oauthScopes, + viper.GetString("host"), + viper.GetInt("oauth_callback_port"), + ) + oauthMgr = oauth.NewManager(oauthCfg) + fmt.Fprintf(os.Stderr, "OAuth configured - will prompt for authentication when needed\n") + } else { + fmt.Fprintf(os.Stderr, "Warning: No authentication configured\n") + fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN, or\n") + fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id\n") + fmt.Fprintf(os.Stderr, "Tools will prompt for authentication when called\n") + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), Token: token, + OAuthScopes: oauthScopes, + PrebuiltInventory: prebuiltInventory, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, @@ -86,6 +124,11 @@ var ( InsiderMode: viper.GetBool("insider-mode"), RepoAccessCacheTTL: &ttl, } + // Only set OAuthManager if not nil - interface nil check requires this pattern + // to avoid a non-nil interface containing a nil pointer + if oauthMgr != nil { + stdioServerConfig.OAuthManager = oauthMgr + } return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -112,6 +155,12 @@ func init() { rootCmd.PersistentFlags().Bool("insider-mode", false, "Enable insider features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // OAuth flags (stdio mode only) + rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)") + rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (recommended)") + rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)") + rootCmd.PersistentFlags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback (0 for random, required for Docker with -p flag)") + // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) @@ -126,6 +175,10 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insider-mode", rootCmd.PersistentFlags().Lookup("insider-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id")) + _ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret")) + _ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes")) + _ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port")) // Add subcommands rootCmd.AddCommand(stdioCmd) @@ -154,3 +207,93 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { } return pflag.NormalizedName(name) } + +// oauthScopesResult holds the result of OAuth scope computation +type oauthScopesResult struct { + scopes []string + inventory *inventory.Inventory // reused inventory to avoid double building +} + +// getOAuthScopes returns the OAuth scopes to request based on enabled tools +// Also returns the built inventory to avoid building it twice +// Uses custom scopes if explicitly provided, otherwise computes required scopes +// from the tools that will be enabled based on user configuration +func getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures []string, t translations.TranslationHelperFunc) oauthScopesResult { + // Allow explicit override via --oauth-scopes flag + var scopeList []string + if viper.IsSet("oauth_scopes") { + if err := viper.UnmarshalKey("oauth_scopes", &scopeList); err == nil && len(scopeList) > 0 { + // When scopes are explicit, don't build inventory (will be built in server) + return oauthScopesResult{scopes: scopeList} + } + } + + // Build inventory with the same configuration that will be used at runtime + // This allows us to determine which tools will actually be available + // and avoids building the inventory twice + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: t, + ReadOnly: viper.GetBool("read-only"), + Toolsets: enabledToolsets, + Tools: enabledTools, + EnabledFeatures: enabledFeatures, + }) + + inv, err := inventoryBuilder.Build() + if err != nil { + // Inventory build only fails if invalid tool names are passed via --tools + // In that case, return empty scopes - the error will surface when server starts + return oauthScopesResult{scopes: nil} + } + + // Collect all required scopes from available tools + // This is the canonical source of OAuth scopes for the enabled tools + requiredScopes := collectRequiredScopes(inv) + return oauthScopesResult{scopes: requiredScopes, inventory: inv} +} + +// collectRequiredScopes collects all unique required scopes from available tools +// Returns a sorted, deduplicated list of OAuth scopes needed for the enabled tools +func collectRequiredScopes(inv *inventory.Inventory) []string { + scopeSet := make(map[string]bool) + + // Get available tools (respects filters like read-only, toolsets, etc.) + for _, tool := range inv.AvailableTools(context.Background()) { + for _, scope := range tool.RequiredScopes { + if scope != "" { + scopeSet[scope] = true + } + } + } + + // Convert to sorted slice for deterministic output + scopes := make([]string, 0, len(scopeSet)) + for scope := range scopeSet { + scopes = append(scopes, scope) + } + sort.Strings(scopes) + + return scopes +} + +// resolveOAuthCredentials returns OAuth client credentials using the following priority: +// 1. Explicit configuration via flags/environment (--oauth-client-id, GITHUB_OAUTH_CLIENT_ID) +// 2. Build-time baked credentials (for official releases) +// +// This allows developers to override with their own OAuth app while providing +// a seamless "just works" experience for end users of official builds. +func resolveOAuthCredentials() (clientID, clientSecret string) { + // Priority 1: Explicit user configuration + clientID = viper.GetString("oauth_client_id") + if clientID != "" { + return clientID, viper.GetString("oauth_client_secret") + } + + // Priority 2: Build-time baked credentials + if buildinfo.HasOAuthCredentials() { + return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret + } + + // No OAuth credentials available + return "", "" +} diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 000000000..0da142628 --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,207 @@ +# OAuth Authentication + +The GitHub MCP Server supports OAuth authentication for stdio mode, enabling interactive authentication when no Personal Access Token (PAT) is configured. + +## Overview + +OAuth authentication allows users to authenticate with GitHub through their browser without pre-configuring a token. This is useful for: + +- **End users** who want authentication to "just work" without configuration +- **Interactive sessions** where users want to authenticate on-demand +- **Docker deployments** where tokens shouldn't be baked into images +- **Multi-user scenarios** where each user authenticates individually + +## How It Works + +Official releases of the GitHub MCP Server include built-in OAuth credentials, providing a seamless authentication experience. When you run the server without a PAT configured, it will automatically prompt for OAuth authentication when a tool requires it. + +### Authentication Priority + +The server uses the following priority for authentication: + +1. **Personal Access Token** (GITHUB_PERSONAL_ACCESS_TOKEN) - Highest priority, explicit user choice +2. **Explicit OAuth configuration** (--oauth-client-id flag/env) - Developer/power user override +3. **Built-in OAuth credentials** - Default for official releases, "just works" +4. **No authentication** - Warning displayed, tools will fail when called + +## Quick Start + +For most users, simply run the server without any configuration: + +```bash +# Official releases include built-in OAuth - just run and authenticate when prompted +./github-mcp-server stdio +``` + +The server will prompt for browser-based authentication when you first call a tool that requires GitHub access. + +## Developer Configuration + +Developers building from source or wanting to use their own OAuth app can provide credentials explicitly. + +### Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | For custom OAuth apps | +| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret | Recommended | + +### Command Line Flags + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | Override OAuth app client ID | +| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Override OAuth app client secret | +| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed port for OAuth callback (required for Docker with `-p` flag) | +| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Custom OAuth scopes (comma-separated) | + +### Building with Custom OAuth Credentials + +When building from source, you can bake in your own OAuth credentials: + +```bash +# Build with custom OAuth credentials +go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=your-client-id \ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=your-client-secret" \ + ./cmd/github-mcp-server + +# Or use environment variables during development +export GITHUB_OAUTH_CLIENT_ID="your-client-id" +export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" +./github-mcp-server stdio +``` + +For Docker builds: + +```bash +docker build \ + --build-arg OAUTH_CLIENT_ID="your-client-id" \ + --build-arg OAUTH_CLIENT_SECRET="your-client-secret" \ + -t github-mcp-server . +``` + +## Authentication Flows + +The server automatically selects the appropriate OAuth flow based on the environment: + +### 1. PKCE Flow (Browser-based) + +Used for local binary execution where a browser can be opened: + +1. Server starts a local callback server +2. Browser opens to GitHub authorization page +3. User authorizes the application +4. GitHub redirects to local callback with authorization code +5. Server exchanges code for access token + +### 2. Device Flow (Docker/Headless) + +Used when running in Docker or when a browser cannot be opened: + +1. Server requests a device code from GitHub +2. User is shown a URL and code to enter +3. User visits `github.com/login/device` and enters the code +4. Server polls GitHub until authorization is complete +5. Access token is retrieved + +## Usage Examples + +### Local Binary (Official Release) + +```bash +# Official releases have built-in OAuth - just run! +./github-mcp-server stdio +# Authentication will be prompted when a tool is called +``` + +### Local Binary (Custom OAuth) + +```bash +# Override with your own OAuth app +export GITHUB_OAUTH_CLIENT_ID="your-client-id" +export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" +./github-mcp-server stdio +``` + +### Docker (Official Image) + +```bash +# Official images have built-in OAuth - just run! +docker run -i --rm ghcr.io/github/github-mcp-server stdio +``` + +### Docker (with Custom OAuth) + +```bash +docker run -i --rm \ + -e GITHUB_OAUTH_CLIENT_ID="your-client-id" \ + -e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \ + ghcr.io/github/github-mcp-server stdio +``` + +### Docker (with PKCE Flow via port mapping) + +```bash +docker run -i --rm \ + --network=host \ + ghcr.io/github/github-mcp-server stdio --oauth-callback-port=8085 +``` + +### VS Code MCP Configuration + +```jsonc +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "ghcr.io/github/github-mcp-server", + "stdio" + ], + "type": "stdio" + } + } +} +``` + +## Creating an OAuth App + +1. Go to **GitHub Settings** → **Developer settings** → **OAuth Apps** +2. Click **New OAuth App** +3. Fill in the details: + - **Application name**: Your app name (e.g., "GitHub MCP Server") + - **Homepage URL**: Your homepage or `https://github.com/github/github-mcp-server` + - **Authorization callback URL**: `http://localhost:8085/callback` (or your chosen port) +4. Click **Register application** +5. Copy the **Client ID** +6. Generate and copy the **Client Secret** + +## Scope Computation + +The server automatically computes the required OAuth scopes based on enabled tools: + +- If `--toolsets` or `--tools` are specified, only scopes for those tools are requested +- If no tools are specified, default scopes are used: `repo`, `user`, `gist`, `notifications`, `read:org`, `project` +- Custom scopes can be specified with `--oauth-scopes` + +## Security Considerations + +1. **Client Secret**: While optional for public OAuth apps, using a client secret is recommended for better security +2. **Token Storage**: OAuth tokens are stored in memory only and not persisted to disk +3. **Scope Minimization**: Request only the scopes needed for your use case +4. **PKCE**: The PKCE flow provides protection against authorization code interception attacks + +## Troubleshooting + +### "redirect_uri not associated with this client" + +Ensure the callback port matches your OAuth app's registered callback URL. Use `--oauth-callback-port` to specify the exact port. + +### Browser doesn't open automatically + +The server will fall back to displaying the authorization URL. In Docker, the device flow is used automatically. + +### Token not being used + +Verify that `GITHUB_PERSONAL_ACCESS_TOKEN` is not set, as it takes precedence over OAuth. diff --git a/go.mod b/go.mod index 5322b47ec..8e2fd92b4 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.30.0 golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 000000000..9128786d6 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,31 @@ +// Package buildinfo contains build-time injected values. +// +// These values are set via -ldflags during the build process. +// For example: +// +// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +// +// The OAuth credentials are used as default values for stdio mode when no +// PAT or explicit OAuth configuration is provided. This enables a "just works" +// experience for most users while still allowing developer overrides. +// +// Note: These credentials are intentionally baked into the binary. While they +// can be reverse-engineered, this provides a barrier against trivial cloning +// and establishes clear provenance for the official GitHub MCP Server. +package buildinfo + +// OAuthClientID is the default GitHub OAuth App Client ID. +// Set at build time via -ldflags for official releases. +// Empty string means no default OAuth credentials are available. +var OAuthClientID string + +// OAuthClientSecret is the default GitHub OAuth App Client Secret. +// Set at build time via -ldflags for official releases. +// While called a "secret", OAuth client secrets in native apps cannot truly +// be kept secret and are considered public per RFC 8252. +var OAuthClientSecret string + +// HasOAuthCredentials returns true if build-time OAuth credentials are available. +func HasOAuthCredentials() bool { + return OAuthClientID != "" +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 6090063f1..6bf21fbe6 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -36,6 +36,15 @@ type MCPServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // TokenProvider is an optional function to dynamically get the token. + // Used for OAuth flows where the token is obtained after server startup. + // If set, this takes precedence over Token for API requests. + TokenProvider func() string + + // PrebuiltInventory is an optional pre-built inventory to avoid double building + // When set, this inventory will be used instead of building a new one + PrebuiltInventory *inventory.Inventory + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -88,9 +97,19 @@ type githubClients struct { } // createGitHubClients creates all the GitHub API clients needed by the server. -func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) { - // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) +// If tokenProviderFn is provided, it will be used to get the token dynamically (for OAuth). +// Otherwise, cfg.Token is used as a static token. +func createGitHubClients(cfg MCPServerConfig, apiHost apiHost, tokenProviderFn tokenProvider) (*githubClients, error) { + // Create bearer auth transport that can use dynamic token + restTransport := &bearerAuthTransport{ + transport: http.DefaultTransport, + token: cfg.Token, + tokenProvider: tokenProviderFn, + } + + // Construct REST client with custom transport + restHTTPClient := &http.Client{Transport: restTransport} + restClient := gogithub.NewClient(restHTTPClient) restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) restClient.BaseURL = apiHost.baseRESTURL restClient.UploadURL = apiHost.uploadURL @@ -102,12 +121,13 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, transport: &github.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, - token: cfg.Token, + token: cfg.Token, + tokenProvider: tokenProviderFn, }, } gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) - // Create raw content client (shares REST client's HTTP transport) + // Create raw content client (inherits transport from REST client) rawClient := raw.NewClient(restClient, apiHost.rawURL) // Set up repo access cache for lockdown mode @@ -164,7 +184,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { return nil, fmt.Errorf("failed to parse API host: %w", err) } - clients, err := createGitHubClients(cfg, apiHost) + clients, err := createGitHubClients(cfg, apiHost, cfg.TokenProvider) if err != nil { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } @@ -204,7 +224,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) // Create feature checker - featureChecker := createFeatureChecker(cfg.EnabledFeatures) + featureChecker := inventory.NewSliceFeatureChecker(cfg.EnabledFeatures) // Create dependencies for tool handlers deps := github.NewBaseDeps( @@ -215,7 +235,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { cfg.Translator, github.FeatureFlags{ LockdownMode: cfg.LockdownMode, - InsiderMode: cfg.InsiderMode, + InsiderMode: cfg.InsiderMode, }, cfg.ContentWindowSize, featureChecker, @@ -229,24 +249,52 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { }) // Build and register the tool/resource/prompt inventory - inventoryBuilder := github.NewInventory(cfg.Translator). - WithDeprecatedAliases(github.DeprecatedToolAliases). - WithReadOnly(cfg.ReadOnly). - WithToolsets(enabledToolsets). - WithTools(cfg.EnabledTools). - WithFeatureChecker(featureChecker) - - // Apply token scope filtering if scopes are known (for PAT filtering) - if cfg.TokenScopes != nil { - inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) - } - - inventory, err := inventoryBuilder.Build() - if err != nil { - return nil, fmt.Errorf("failed to build inventory: %w", err) + var inv *inventory.Inventory + if cfg.PrebuiltInventory != nil { + // Use prebuilt inventory to avoid double building + inv = cfg.PrebuiltInventory + + // Apply scope filtering if needed (only if not already applied) + // Prebuilt inventory from OAuth scope computation doesn't have scope filter yet + if cfg.TokenScopes != nil { + // Need to rebuild with scope filter + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: cfg.Translator, + ReadOnly: cfg.ReadOnly, + Toolsets: enabledToolsets, + Tools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + }).WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + + var err error + inv, err = inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to rebuild inventory with scope filter: %w", err) + } + } + } else { + // Build inventory from scratch + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: cfg.Translator, + ReadOnly: cfg.ReadOnly, + Toolsets: enabledToolsets, + Tools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + }) + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + var err error + inv, err = inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build inventory: %w", err) + } } - if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { + if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) } @@ -254,12 +302,12 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets // is empty - users enable toolsets at runtime via the dynamic tools below (but can // enable toolsets or tools explicitly that do need registration). - inventory.RegisterAll(context.Background(), ghServer, deps) + inv.RegisterAll(context.Background(), ghServer, deps) // Register dynamic toolset management tools (enable/disable) - these are separate // meta-tools that control the inventory, not part of the inventory itself if cfg.DynamicToolsets { - registerDynamicTools(ghServer, inventory, deps, cfg.Translator) + registerDynamicTools(ghServer, inv, deps, cfg.Translator) } return ghServer, nil @@ -278,20 +326,6 @@ func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, de } } -// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name -// is present in the provided list of enabled features. For the local server, -// this is populated from the --features CLI flag. -func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker { - // Build a set for O(1) lookup - featureSet := make(map[string]bool, len(enabledFeatures)) - for _, f := range enabledFeatures { - featureSet[f] = true - } - return func(_ context.Context, flagName string) (bool, error) { - return featureSet[flagName], nil - } -} - type StdioServerConfig struct { // Version of the server Version string @@ -302,6 +336,22 @@ type StdioServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // OAuthManager handles OAuth authentication with lazy loading + // When set, tools will trigger OAuth flow when authentication is needed + OAuthManager interface { + HasToken() bool + GetAccessToken() string + RequestAuthentication(context.Context, *mcp.ServerSession) error + } + + // OAuthScopes contains the OAuth scopes that were requested + // When non-nil and OAuthManager is set, these scopes are used for scope filtering + OAuthScopes []string + + // PrebuiltInventory is an optional pre-built inventory to avoid double building + // When set, this inventory will be used instead of building a new one + PrebuiltInventory *inventory.Inventory + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -372,7 +422,8 @@ func RunStdioServer(cfg StdioServerConfig) error { // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. // Fine-grained PATs and other token types don't support this, so we skip filtering. var tokenScopes []string - if strings.HasPrefix(cfg.Token, "ghp_") { + switch { + case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) @@ -380,14 +431,32 @@ func RunStdioServer(cfg StdioServerConfig) error { tokenScopes = fetchedScopes logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } - } else { + case len(cfg.OAuthScopes) > 0: + // Use OAuth scopes for filtering when OAuth is configured + // This filters tools to only those compatible with the requested OAuth scopes + tokenScopes = cfg.OAuthScopes + logger.Info("using OAuth scopes for tool filtering", "scopes", tokenScopes) + default: logger.Debug("skipping scope filtering for non-PAT token") } + // Create token provider that checks OAuth first, then falls back to static token + var tokenProvider func() string + if cfg.OAuthManager != nil { + tokenProvider = func() string { + if token := cfg.OAuthManager.GetAccessToken(); token != "" { + return token + } + return cfg.Token + } + } + ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, + TokenProvider: tokenProvider, + PrebuiltInventory: cfg.PrebuiltInventory, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, @@ -405,6 +474,11 @@ func RunStdioServer(cfg StdioServerConfig) error { return fmt.Errorf("failed to create MCP server: %w", err) } + // Add OAuth authentication middleware if OAuth manager is configured + if cfg.OAuthManager != nil { + ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) + } + if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() @@ -633,14 +707,24 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error return t.transport.RoundTrip(req) } +// tokenProvider is a function that returns the current auth token +type tokenProvider func() string + type bearerAuthTransport struct { - transport http.RoundTripper - token string + transport http.RoundTripper + token string // static token (used if tokenProvider is nil) + tokenProvider tokenProvider // dynamic token provider (takes precedence) } func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+t.token) + token := t.token + if t.tokenProvider != nil { + token = t.tokenProvider() + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } return t.transport.RoundTrip(req) } @@ -699,3 +783,43 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, return fetcher.FetchTokenScopes(ctx, token) } + +// createOAuthMiddleware creates middleware that triggers OAuth authentication when needed +func createOAuthMiddleware(oauthMgr interface { + HasToken() bool + GetAccessToken() string + RequestAuthentication(context.Context, *mcp.ServerSession) error +}, logger *slog.Logger) func(mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // Only check authentication for tool calls + if method != "tools/call" { + return next(ctx, method, req) + } + + // Check if we have a token + if !oauthMgr.HasToken() { + logger.Info("no authentication token available, triggering OAuth flow") + + // Get the session for elicitation + var session *mcp.ServerSession + if sess := req.GetSession(); sess != nil { + // Type assert to ServerSession + if ss, ok := sess.(*mcp.ServerSession); ok { + session = ss + } + } + + // Trigger OAuth authentication (blocks until complete) + if err := oauthMgr.RequestAuthentication(ctx, session); err != nil { + return nil, err + } + // OAuth completed successfully - fall through to execute the tool + logger.Info("OAuth authentication completed successfully") + } + + // Execute the tool with authentication + return next(ctx, method, req) + } + } +} diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go new file mode 100644 index 000000000..8705e8b59 --- /dev/null +++ b/internal/oauth/manager.go @@ -0,0 +1,305 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/oauth2" +) + +// Manager handles OAuth authentication state with URL elicitation support +type Manager struct { + config Config + mu sync.RWMutex + token *Result + authInProgress bool + authDone chan struct{} // closed when auth completes +} + +// NewManager creates a new OAuth manager with the given configuration +func NewManager(cfg Config) *Manager { + return &Manager{ + config: cfg, + } +} + +// HasToken returns true if a valid token is available +func (m *Manager) HasToken() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.token != nil && m.token.AccessToken != "" +} + +// GetAccessToken returns the access token if available +func (m *Manager) GetAccessToken() string { + m.mu.RLock() + defer m.mu.RUnlock() + if m.token == nil { + return "" + } + return m.token.AccessToken +} + +// RequestAuthentication triggers the OAuth flow using URL elicitation +// Uses session.Elicit() for synchronous blocking auth if session is provided +// Falls back to URLElicitationRequiredError if session is not available +// If auth is already in progress, waits for it to complete instead of starting a new flow +func (m *Manager) RequestAuthentication(ctx context.Context, session *mcp.ServerSession) error { + // Check if auth is already in progress + m.mu.Lock() + if m.authInProgress { + // Wait for the existing auth to complete + authDone := m.authDone + m.mu.Unlock() + + select { + case <-authDone: + // Auth completed, check if we have a token now + if m.HasToken() { + return nil + } + // Auth failed, but don't start a new one - let the next request retry + return fmt.Errorf("authentication failed") + case <-ctx.Done(): + return ctx.Err() + } + } + + // Mark auth as in progress + m.authInProgress = true + m.authDone = make(chan struct{}) + m.mu.Unlock() + + // Ensure we clean up the in-progress state when done + defer func() { + m.mu.Lock() + m.authInProgress = false + close(m.authDone) + m.mu.Unlock() + }() + + // Determine which flow to use based on environment + useDeviceFlow := isRunningInDocker() && m.config.CallbackPort == 0 + + if useDeviceFlow { + return m.startDeviceFlowWithElicitation(ctx, session) + } + + return m.startPKCEFlowWithElicitation(ctx, session) +} + +// startDeviceFlowWithElicitation initiates device flow and uses session elicitation. +// Device flow is used when a callback server cannot be started (e.g., in Docker containers). +// It displays a code that the user must enter at the verification URL. +func (m *Manager) startDeviceFlowWithElicitation(ctx context.Context, session *mcp.ServerSession) error { + oauth2Cfg := &oauth2.Config{ + ClientID: m.config.ClientID, + ClientSecret: m.config.ClientSecret, + Scopes: m.config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: m.config.AuthURL, + TokenURL: m.config.TokenURL, + DeviceAuthURL: m.config.DeviceAuthURL, + }, + } + + // Request device authorization + deviceAuth, err := oauth2Cfg.DeviceAuth(ctx) + if err != nil { + return fmt.Errorf("failed to get device authorization: %w", err) + } + + // Create cancellable context for polling + pollCtx, cancelPoll := context.WithCancel(ctx) + defer cancelPoll() + + // Use session elicitation if available to show the user the verification URL and code + if session != nil { + // Run elicitation in goroutine - if cancelled, abort the device flow + go func() { + elicitID, err := generateRandomToken() + if err != nil { + // Non-critical: use fallback ID if generation fails + elicitID = "fallback-id" + } + // Use pollCtx so elicitation is cancelled when polling completes or is cancelled + result, err := session.Elicit(pollCtx, &mcp.ElicitParams{ + Mode: "url", + URL: deviceAuth.VerificationURI, + ElicitationID: elicitID, + Message: fmt.Sprintf("GitHub OAuth Device Authorization\n\nYour code: %s\n\nVisit the URL and enter this code to authenticate.", deviceAuth.UserCode), + }) + // If elicitation was cancelled or declined, abort the polling + if err != nil || result == nil || result.Action == "cancel" || result.Action == "decline" { + cancelPoll() + } + }() + } + + // Poll for the token (blocking, but respects context cancellation) + token, err := oauth2Cfg.DeviceAccessToken(pollCtx, deviceAuth) + if err != nil { + if pollCtx.Err() != nil { + return fmt.Errorf("OAuth authorization was cancelled by user") + } + return fmt.Errorf("failed to get device access token: %w", err) + } + + // Store the token + m.setToken(&Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }) + + return nil +} + +// startPKCEFlowWithElicitation initiates PKCE flow with browser and session elicitation +// Uses session.Elicit() for synchronous blocking auth - the request waits until auth completes +func (m *Manager) startPKCEFlowWithElicitation(ctx context.Context, session *mcp.ServerSession) error { + // Generate PKCE verifier + verifier, err := generatePKCEVerifier() + if err != nil { + // Fall back to device flow if PKCE setup fails + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Generate state for CSRF protection + state, err := generateRandomToken() + if err != nil { + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Start local callback server + listener, port, err := startLocalServer(m.config.CallbackPort) + if err != nil { + // Cannot start callback server - fall back to device flow + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Create OAuth2 config + oauth2Cfg := &oauth2.Config{ + ClientID: m.config.ClientID, + ClientSecret: m.config.ClientSecret, + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", port), + Scopes: m.config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: m.config.AuthURL, + TokenURL: m.config.TokenURL, + }, + } + + // Build authorization URL with PKCE + authURL := oauth2Cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + + // Setup callback handling + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Create and start callback server + server := createCallbackServer(state, codeChan, errChan, listener) + + // Cleanup function + cleanup := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + _ = listener.Close() // Error intentionally ignored in cleanup + } + + // Try to open browser - if it works, no elicitation needed + browserErr := openBrowser(authURL) + + // Channel to signal elicitation cancellation + elicitCancelChan := make(chan struct{}, 1) + + // Create cancellable context for elicitation + elicitCtx, cancelElicit := context.WithCancel(ctx) + defer cancelElicit() + + // Only elicit if browser failed to open (e.g., headless environment) + // and we need to show the user the URL manually + if browserErr != nil && session != nil { + // Run elicitation in goroutine so we can monitor callback in parallel + go func() { + elicitID, _ := generateRandomToken() // Non-critical: empty ID is acceptable + // Use elicitCtx so elicitation is cancelled when auth completes + result, err := session.Elicit(elicitCtx, &mcp.ElicitParams{ + Mode: "url", + URL: authURL, + ElicitationID: elicitID, + Message: "GitHub OAuth Authorization\n\nPlease visit the URL to authorize access.", + }) + // If elicitation was cancelled or declined, signal to abort + if err != nil || result == nil || result.Action == "cancel" || result.Action == "decline" { + select { + case elicitCancelChan <- struct{}{}: + default: + } + } + }() + } + + // Wait for callback with timeout + select { + case code := <-codeChan: + // Exchange code for token + token, err := oauth2Cfg.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + cleanup() + if err != nil { + return fmt.Errorf("failed to exchange code for token: %w", err) + } + + // Store token + m.setToken(&Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }) + + return nil + + case err := <-errChan: + cleanup() + return fmt.Errorf("OAuth callback error: %w", err) + + case <-elicitCancelChan: + cleanup() + return fmt.Errorf("OAuth authorization was cancelled by user") + + case <-ctx.Done(): + cleanup() + return ctx.Err() + + case <-time.After(DefaultAuthTimeout): + cleanup() + return fmt.Errorf("OAuth timeout after %v - please try again", DefaultAuthTimeout) + } +} + +// setToken stores the OAuth token +func (m *Manager) setToken(token *Result) { + m.mu.Lock() + defer m.mu.Unlock() + m.token = token +} + +// Helper functions + +// generateRandomToken generates a cryptographically random URL-safe token. +// Used for CSRF state and elicitation IDs. +func generateRandomToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 000000000..b6ac0ecff --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,270 @@ +package oauth + +import ( + "crypto/rand" + "embed" + "encoding/base64" + "fmt" + "html/template" + "io" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var ( + errorTemplate *template.Template + successTemplate *template.Template +) + +func init() { + var err error + errorTemplate, err = template.ParseFS(templateFS, "templates/error.html") + if err != nil { + panic(fmt.Sprintf("failed to parse error template: %v", err)) + } + successTemplate, err = template.ParseFS(templateFS, "templates/success.html") + if err != nil { + panic(fmt.Sprintf("failed to parse success template: %v", err)) + } +} + +const ( + // DefaultAuthTimeout is the default timeout for the OAuth authorization flow + DefaultAuthTimeout = 5 * time.Minute +) + +// Config holds the OAuth configuration +type Config struct { + ClientID string + ClientSecret string // Recommended for GitHub OAuth apps + RedirectURL string + Scopes []string + AuthURL string + TokenURL string + Host string // GitHub host (for constructing OAuth URLs) + DeviceAuthURL string // Device authorization URL (for device flow) + CallbackPort int // Fixed callback port (0 for random) +} + +// Result contains the OAuth flow result. +// +// Note: This implementation does not currently perform automatic token refresh. +// GitHub OAuth tokens for OAuth Apps do not expire, but GitHub Apps tokens do. +// Callers should handle re-authentication when API calls fail with auth errors. +type Result struct { + AccessToken string + RefreshToken string // Captured but not currently used for automatic refresh + TokenType string + Expiry time.Time // Zero value if token does not expire +} + +// generatePKCEVerifier generates a PKCE code verifier +func generatePKCEVerifier() (string, error) { + // Generate 32 random bytes (256 bits) + // Base64URL encoding of 32 bytes gives us 43 characters + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + return verifier, nil +} + +// isRunningInDocker detects if the process is running inside a Docker container. +// This detection is used to determine whether to use device flow (no browser available) +// or PKCE flow (browser can be opened). On non-Linux systems, this always returns false +// since the detection relies on Linux-specific paths. +func isRunningInDocker() bool { + // Docker detection only works on Linux where /proc filesystem exists + if runtime.GOOS != "linux" { + return false + } + + // Check for .dockerenv file (most common indicator) + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check cgroup for docker (fallback) + data, err := os.ReadFile("/proc/1/cgroup") + if err == nil && (strings.Contains(string(data), "docker") || strings.Contains(string(data), "containerd")) { + return true + } + + return false +} + +// startLocalServer starts a local HTTP server on the specified port +// If port is 0, uses a random available port +func startLocalServer(port int) (net.Listener, int, error) { + addr := fmt.Sprintf("localhost:%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, 0, fmt.Errorf("failed to start listener on %s: %w", addr, err) + } + + actualPort := listener.Addr().(*net.TCPAddr).Port + return listener, actualPort, nil +} + +// createCallbackHandler creates an HTTP handler for the OAuth callback +func createCallbackHandler(expectedState string, codeChan chan<- string, errChan chan<- error) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + // Check for errors from OAuth provider + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + errDesc := r.URL.Query().Get("error_description") + if errDesc != "" { + errMsg = fmt.Sprintf("%s: %s", errMsg, errDesc) + } + errChan <- fmt.Errorf("authorization failed: %s", errMsg) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // html/template auto-escapes ErrorMessage to prevent XSS + if err := errorTemplate.Execute(w, struct{ ErrorMessage string }{ErrorMessage: errMsg}); err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + } + return + } + + // Verify state for CSRF protection + if state := r.URL.Query().Get("state"); state != expectedState { + errChan <- fmt.Errorf("state mismatch (possible CSRF attack)") + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + // Get authorization code + code := r.URL.Query().Get("code") + if code == "" { + errChan <- fmt.Errorf("no authorization code received") + http.Error(w, "No code received", http.StatusBadRequest) + return + } + + // Send code to channel + codeChan <- code + + // Display success page + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := successTemplate.Execute(w, nil); err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + } + }) + + return mux +} + +// createCallbackServer creates an HTTP server for the OAuth callback +// Used by Manager for proper lifecycle management +func createCallbackServer(expectedState string, codeChan chan<- string, errChan chan<- error, listener net.Listener) *http.Server { + handler := createCallbackHandler(expectedState, codeChan, errChan) + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, // Prevent Slowloris attacks + } + + // Start server in background + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + return server +} + +// openBrowser tries to open the URL in the default browser +func openBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "linux": + // Try xdg-open first (most Linux distributions) + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + // Redirect output to prevent noise + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + return cmd.Start() +} + +// GetGitHubOAuthConfig returns the GitHub OAuth configuration for the specified host +// host can be empty for github.com, or a full URL like "https://github.enterprise.com" for GHES +func GetGitHubOAuthConfig(clientID, clientSecret string, scopes []string, host string, callbackPort int) Config { + authURL, tokenURL, deviceAuthURL := getOAuthEndpoints(host) + + return Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + AuthURL: authURL, + TokenURL: tokenURL, + DeviceAuthURL: deviceAuthURL, + Host: host, + CallbackPort: callbackPort, + } +} + +// getOAuthEndpoints returns the appropriate OAuth endpoints based on the host +func getOAuthEndpoints(host string) (authURL, tokenURL, deviceAuthURL string) { + // Default to github.com + if host == "" { + return "https://github.com/login/oauth/authorize", + "https://github.com/login/oauth/access_token", + "https://github.com/login/device/code" + } + + // For GHES/GHEC, OAuth endpoints are at the main domain, not api subdomain + // Parse the host to extract the base domain + hostURL := host + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + hostURL = "https://" + host + } + + // Extract scheme and hostname + var scheme, hostname string + if strings.HasPrefix(hostURL, "https://") { + scheme = "https" + hostname = strings.TrimPrefix(hostURL, "https://") + } else if strings.HasPrefix(hostURL, "http://") { + scheme = "http" + hostname = strings.TrimPrefix(hostURL, "http://") + } + + // Remove any trailing slashes or paths + // strings.Index returns -1 if not found, and we want to keep everything if there's no slash + // If slash is at index 0, that would be invalid (e.g., "/example"), so we check > 0 + if idx := strings.Index(hostname, "/"); idx > 0 { + hostname = hostname[:idx] + } + + // For github.com, strip api. subdomain (api.github.com → github.com) + // For ghe.com (GHEC), keep the full tenant domain (mycompany.ghe.com stays as-is) + if hostname == "api.github.com" { + hostname = "github.com" + } + + authURL = fmt.Sprintf("%s://%s/login/oauth/authorize", scheme, hostname) + tokenURL = fmt.Sprintf("%s://%s/login/oauth/access_token", scheme, hostname) + deviceAuthURL = fmt.Sprintf("%s://%s/login/device/code", scheme, hostname) + + return authURL, tokenURL, deviceAuthURL +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 000000000..3cf0e3ac8 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,194 @@ +package oauth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // expectedPKCEVerifierMinLength is the expected minimum length of a PKCE verifier + // Base64URL encoding of 32 bytes = 43 characters (32 * 8 / 6, rounded up) + expectedPKCEVerifierMinLength = 43 +) + +func TestGeneratePKCEVerifier(t *testing.T) { + verifier, err := generatePKCEVerifier() + require.NoError(t, err) + require.NotEmpty(t, verifier) + + // Verifier should be at least 43 characters (base64url of 32 bytes) + assert.GreaterOrEqual(t, len(verifier), expectedPKCEVerifierMinLength) + + // Generate another one to ensure they're different + verifier2, err := generatePKCEVerifier() + require.NoError(t, err) + assert.NotEqual(t, verifier, verifier2) +} + +func TestGetGitHubOAuthConfig(t *testing.T) { + clientID := "test-client-id" + clientSecret := "test-client-secret" + scopes := []string{"repo", "user"} + + t.Run("default github.com", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "", 0) + + assert.Equal(t, clientID, cfg.ClientID) + assert.Equal(t, clientSecret, cfg.ClientSecret) + assert.Equal(t, scopes, cfg.Scopes) + assert.Equal(t, "https://github.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://github.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://github.com/login/device/code", cfg.DeviceAuthURL) + assert.Equal(t, "", cfg.Host) + assert.Equal(t, 0, cfg.CallbackPort) + }) + + t.Run("GHES host", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://github.enterprise.com", 8080) + + assert.Equal(t, clientID, cfg.ClientID) + assert.Equal(t, clientSecret, cfg.ClientSecret) + assert.Equal(t, scopes, cfg.Scopes) + assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://github.enterprise.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://github.enterprise.com/login/device/code", cfg.DeviceAuthURL) + assert.Equal(t, "https://github.enterprise.com", cfg.Host) + assert.Equal(t, 8080, cfg.CallbackPort) + }) + + t.Run("GHEC host (ghe.com)", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://mycompany.ghe.com", 0) + + assert.Equal(t, "https://mycompany.ghe.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://mycompany.ghe.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://mycompany.ghe.com/login/device/code", cfg.DeviceAuthURL) + }) + + t.Run("host without scheme", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "github.enterprise.com", 0) + + // Should default to https + assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL) + }) +} + +func TestStartLocalServer(t *testing.T) { + t.Run("random port", func(t *testing.T) { + listener, port, err := startLocalServer(0) + require.NoError(t, err) + require.NotNil(t, listener) + defer listener.Close() + + assert.Greater(t, port, 0) + assert.Less(t, port, 65536) + }) + + t.Run("fixed port", func(t *testing.T) { + // Use a high port to avoid conflicts + fixedPort := 54321 + listener, port, err := startLocalServer(fixedPort) + require.NoError(t, err) + require.NotNil(t, listener) + defer listener.Close() + + assert.Equal(t, fixedPort, port) + }) +} + +// Manager tests + +func TestNewManager(t *testing.T) { + cfg := Config{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + Scopes: []string{"repo"}, + } + + mgr := NewManager(cfg) + + assert.NotNil(t, mgr) + // Test observable behavior, not internal state + assert.False(t, mgr.HasToken()) + assert.Empty(t, mgr.GetAccessToken()) +} + +func TestManagerHasToken(t *testing.T) { + mgr := NewManager(Config{}) + + t.Run("no token initially", func(t *testing.T) { + assert.False(t, mgr.HasToken()) + }) + + t.Run("has token after setting", func(t *testing.T) { + mgr.setToken(&Result{ + AccessToken: "test-token", + TokenType: "Bearer", + }) + + assert.True(t, mgr.HasToken()) + }) + + t.Run("no token if empty access token", func(t *testing.T) { + mgr.setToken(&Result{ + AccessToken: "", + TokenType: "Bearer", + }) + + assert.False(t, mgr.HasToken()) + }) +} + +func TestManagerGetAccessToken(t *testing.T) { + mgr := NewManager(Config{}) + + t.Run("empty initially", func(t *testing.T) { + assert.Empty(t, mgr.GetAccessToken()) + }) + + t.Run("returns token after setting", func(t *testing.T) { + expectedToken := "gho_test123456" + mgr.setToken(&Result{ + AccessToken: expectedToken, + TokenType: "Bearer", + RefreshToken: "refresh-token", + Expiry: time.Now().Add(time.Hour), + }) + + assert.Equal(t, expectedToken, mgr.GetAccessToken()) + }) +} + +func TestManagerSetToken(t *testing.T) { + mgr := NewManager(Config{}) + + token := &Result{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + } + + mgr.setToken(token) + + // Verify token is stored correctly + assert.Equal(t, token.AccessToken, mgr.GetAccessToken()) + assert.True(t, mgr.HasToken()) +} + +func TestGenerateRandomToken(t *testing.T) { + token1, err := generateRandomToken() + require.NoError(t, err) + require.NotEmpty(t, token1) + + // Token should be URL-safe base64 encoded + // 16 bytes of random data = ~22 chars in base64url + assert.GreaterOrEqual(t, len(token1), 20) + + // Each call should produce unique token + token2, err := generateRandomToken() + require.NoError(t, err) + assert.NotEqual(t, token1, token2) +} diff --git a/internal/oauth/templates/error.html b/internal/oauth/templates/error.html new file mode 100644 index 000000000..9d8146159 --- /dev/null +++ b/internal/oauth/templates/error.html @@ -0,0 +1,60 @@ + + +
+ + +{{.ErrorMessage}}
+ You can close this window.
+You have successfully authorized the GitHub MCP Server.
+You can close this window and retry your request.
+