diff --git a/README.md b/README.md index 64b68a37a..537831254 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,152 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: +### OAuth Authentication (stdio mode) + +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. + +**The OAuth flow automatically adapts to your environment:** +- **Native binary**: Uses interactive PKCE flow (browser opens automatically) +- **Docker container**: Uses device flow (displays code + URL, no callback needed) +- **Docker with port binding**: Can use PKCE flow with `--oauth-callback-port` + +#### Quick Setup + +**Option 1: Device Flow (Easiest for Docker)** +```bash +# 1. Create GitHub OAuth App at https://github.com/settings/developers +# 2. Run with Docker (device flow automatic) +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 +# → Displays: Visit https://github.com/login/device and enter code: ABCD-1234 +``` + +**Option 2: Interactive Flow (Best UX)** +```bash +# For native binary +export GITHUB_OAUTH_CLIENT_ID=your_client_id +export GITHUB_OAUTH_CLIENT_SECRET=your_client_secret +./github-mcp-server stdio +# → Browser opens automatically + +# For Docker with port binding (requires setup in OAuth app callback) +docker run -i --rm -p 8080:8080 \ + -e GITHUB_OAUTH_CLIENT_ID=your_client_id \ + -e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \ + -e GITHUB_OAUTH_CALLBACK_PORT=8080 \ + ghcr.io/github/github-mcp-server +# → Browser opens automatically (callback works via bound port) +``` + +#### Prerequisites for OAuth + +1. Create a GitHub OAuth App at [https://github.com/settings/developers](https://github.com/settings/developers) + - For native binary: Set callback URL to `http://localhost` (port is dynamic) + - For Docker with port binding: Set callback URL to `http://localhost:PORT/callback` (your chosen port) + - For Docker with device flow: No callback URL needed + +2. Set your OAuth app credentials: + ```bash + export GITHUB_OAUTH_CLIENT_ID=your_client_id + export GITHUB_OAUTH_CLIENT_SECRET=your_client_secret + ``` + +3. Run the server without a PAT: + ```bash + # Native binary - interactive PKCE flow + ./github-mcp-server stdio + + # Docker - device flow (automatic) + 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 + + # Docker with port binding - interactive PKCE flow + docker run -i --rm -p 8080:8080 \ + -e GITHUB_OAUTH_CLIENT_ID=your_client_id \ + -e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \ + -e GITHUB_OAUTH_CALLBACK_PORT=8080 \ + ghcr.io/github/github-mcp-server + ``` + +The server will automatically detect the environment and use the appropriate OAuth flow. + +#### OAuth Configuration Options + +- `--oauth-client-id` / `GITHUB_OAUTH_CLIENT_ID` - Your GitHub OAuth app client ID (required for OAuth flow) +- `--oauth-client-secret` / `GITHUB_OAUTH_CLIENT_SECRET` - Your GitHub OAuth app client secret (required) +- `--oauth-scopes` / `GITHUB_OAUTH_SCOPES` - Comma-separated list of scopes (defaults: `repo,user,gist,notifications,read:org,project`) +- `--oauth-callback-port` / `GITHUB_OAUTH_CALLBACK_PORT` - Fixed callback port for Docker (0 for random) + +Example with custom scopes: +```bash +./github-mcp-server stdio \ + --oauth-client-id YOUR_CLIENT_ID \ + --oauth-client-secret YOUR_CLIENT_SECRET \ + --oauth-scopes repo,user +``` + +#### Pre-configured MCP Host Setup + +OAuth can be pre-configured for MCP hosts (similar to PAT distribution). For Docker with port binding: + +**Claude Desktop/Code:** +```bash +claude mcp add github \ + -e GITHUB_OAUTH_CLIENT_ID=your_client_id \ + -e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \ + -e GITHUB_OAUTH_CALLBACK_PORT=8080 \ + -- docker run -i --rm -p 8080:8080 \ + -e GITHUB_OAUTH_CLIENT_ID \ + -e GITHUB_OAUTH_CLIENT_SECRET \ + -e GITHUB_OAUTH_CALLBACK_PORT \ + ghcr.io/github/github-mcp-server +``` + +**VS Code (settings.json):** +```json +{ + "mcp": { + "servers": { + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-p", "8080:8080", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "-e", "GITHUB_OAUTH_CLIENT_SECRET", + "-e", "GITHUB_OAUTH_CALLBACK_PORT", + "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_CLIENT_ID": "${input:github_oauth_client_id}", + "GITHUB_OAUTH_CLIENT_SECRET": "${input:github_oauth_client_secret}", + "GITHUB_OAUTH_CALLBACK_PORT": "8080" + } + } + } + } +} +``` + +Port binding setup is straightforward and can be automated through installation instructions. + +#### Device Flow vs Interactive Flow + +**Device Flow** (automatic in Docker): +- Displays a verification URL and code +- User visits URL in browser and enters code +- No callback server required +- Works in any environment + +**Interactive PKCE Flow** (automatic for native binary): +- Opens browser automatically +- User approves scopes +- Faster and more seamless +- Requires callback server (localhost) + +> **Note**: OAuth authentication is only available in stdio mode. For remote server usage, use Personal Access Tokens as described above. + ### 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/main.go b/cmd/github-mcp-server/main.go index af59ee0e6..845a75fd4 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,14 +1,18 @@ package main import ( - "errors" + "context" "fmt" "os" + "sort" "strings" "time" "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 +36,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 +67,54 @@ 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 if configured + if token == "" { + oauthClientID := viper.GetString("oauth_client_id") + 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, + viper.GetString("oauth_client_secret"), + 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") + } + } + + // Extract token from OAuth manager if available + if oauthMgr != nil && token == "" { + token = oauthMgr.GetAccessToken() + } + ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), Token: token, + OAuthManager: oauthMgr, + OAuthScopes: oauthScopes, + PrebuiltInventory: prebuiltInventory, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, @@ -112,6 +154,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 +174,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 +206,97 @@ 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 scopes []string + if viper.IsSet("oauth_scopes") { + if err := viper.UnmarshalKey("oauth_scopes", &scopes); err == nil && len(scopes) > 0 { + // When scopes are explicit, don't build inventory (will be built in server) + return oauthScopesResult{scopes: scopes} + } + } + + // 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.NewInventory(t). + WithReadOnly(viper.GetBool("read-only")). + WithToolsets(enabledToolsets). + WithTools(enabledTools). + WithFeatureChecker(createFeatureChecker(enabledFeatures)) + + inv, err := inventoryBuilder.Build() + if err != nil { + // If inventory build fails, fall back to default scopes without inventory + return oauthScopesResult{scopes: getDefaultOAuthScopes()} + } + + // Collect all required scopes from available tools + requiredScopes := collectRequiredScopes(inv) + if len(requiredScopes) == 0 { + // If no tools require scopes, use defaults + return oauthScopesResult{scopes: getDefaultOAuthScopes(), inventory: inv} + } + + return oauthScopesResult{scopes: requiredScopes, inventory: inv} +} + +// getDefaultOAuthScopes returns the default scopes for GitHub MCP Server +// Based on the protected resource metadata at https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp +func getDefaultOAuthScopes() []string { + return []string{ + "repo", + "user", + "gist", + "notifications", + "read:org", + "project", + } +} + +// 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 +} + +// createFeatureChecker creates a feature flag checker from enabled features list +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 + } +} diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 000000000..186d81727 --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,144 @@ +# 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: + +- **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 + +## Configuration + +### Required Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | Yes | +| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret | Recommended | + +### Optional Flags + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--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) | + +## 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 + +```bash +# Set OAuth credentials +export GITHUB_OAUTH_CLIENT_ID="your-client-id" +export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" + +# Run without PAT - OAuth will trigger when tools are called +./github-mcp-server stdio +``` + +### Docker (with Device Flow) + +```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 \ + -e GITHUB_OAUTH_CLIENT_ID="your-client-id" \ + -e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \ + ghcr.io/github/github-mcp-server stdio --oauth-callback-port=8085 +``` + +### VS Code MCP Configuration + +```jsonc +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "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" + ], + "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/ghmcp/server.go b/internal/ghmcp/server.go index 6090063f1..b4af1b38d 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) } @@ -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,51 @@ 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.NewInventory(cfg.Translator). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(cfg.EnabledTools). + WithFeatureChecker(featureChecker). + 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.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)) + } + + 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 +301,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 @@ -302,6 +349,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 +435,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 +444,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 +487,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 +720,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 +796,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..8abe7f5d2 --- /dev/null +++ b/internal/oauth/manager.go @@ -0,0 +1,275 @@ +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) + } + + // Use session elicitation if available to show the user the verification URL and code + if session != nil { + elicitID, err := generateElicitationID() + if err != nil { + // Log warning but continue - elicitation ID is for tracking only + elicitID = "fallback-id" + } + // Elicitation result is not critical - device flow polls independently + _, _ = session.Elicit(ctx, &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), + }) + } + + // Poll for the token (blocking) + token, err := oauth2Cfg.DeviceAccessToken(ctx, deviceAuth) + if err != nil { + 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 := generateState() + 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() + } + + // Try to open browser - if it works, no elicitation needed + browserErr := openBrowser(authURL) + + // 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 { + elicitID, _ := generateElicitationID() + _, _ = session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + URL: authURL, + ElicitationID: elicitID, + Message: "GitHub OAuth Authorization\n\nPlease visit the URL to authorize access.", + }) + } + + // 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 <-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 + +func generateElicitationID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func generateState() (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..6f7a28632 --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,532 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/oauth2" +) + +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 +type Result struct { + AccessToken string + RefreshToken string + TokenType string + Expiry time.Time +} + +// 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 +func isRunningInDocker() bool { + // 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 +} + +// StartDeviceFlow initiates an OAuth device authorization flow +// This is suitable for environments without callback capabilities (like Docker containers) +func StartDeviceFlow(ctx context.Context, cfg Config) (*Result, error) { + oauth2Cfg := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Scopes: cfg.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: cfg.AuthURL, + TokenURL: cfg.TokenURL, + DeviceAuthURL: cfg.DeviceAuthURL, + }, + } + + // Request device authorization + deviceAuth, err := oauth2Cfg.DeviceAuth(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get device authorization: %w", err) + } + + // Display verification instructions to user + fmt.Fprint(os.Stderr, "\n"+strings.Repeat("=", 80)+"\n") + fmt.Fprint(os.Stderr, "GitHub OAuth Device Authorization\n") + fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n") + fmt.Fprintf(os.Stderr, "Please visit: %s\n\n", deviceAuth.VerificationURI) + fmt.Fprintf(os.Stderr, "And enter code: %s\n\n", deviceAuth.UserCode) + fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n") + + // Poll for token + token, err := oauth2Cfg.DeviceAccessToken(ctx, deviceAuth) + if err != nil { + return nil, fmt.Errorf("failed to get device access token: %w", err) + } + + fmt.Fprint(os.Stderr, "\n✓ Authorization successful!\n\n") + + return &Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }, nil +} + +// StartOAuthFlow automatically selects the appropriate OAuth flow based on environment +// - Device flow for Docker containers (no callback server possible) +// - Interactive PKCE flow for native binaries (best UX with browser) +func StartOAuthFlow(ctx context.Context, cfg Config) (*Result, error) { + // Check if we're in Docker + if isRunningInDocker() && cfg.CallbackPort == 0 { + // Docker without explicit callback port - use device flow + log.Printf("Detected Docker environment, using device flow") + return StartDeviceFlow(ctx, cfg) + } + + // Use interactive PKCE flow (browser-based) + return StartInteractiveFlow(ctx, cfg) +} + +// StartInteractiveFlow initiates an interactive OAuth flow with PKCE +// This is intended for stdio mode only and opens a browser for user consent +func StartInteractiveFlow(ctx context.Context, cfg Config) (*Result, error) { + // Generate PKCE verifier + verifier, err := generatePKCEVerifier() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE verifier: %w", err) + } + + // Create OAuth2 config + oauth2Cfg := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Scopes: cfg.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: cfg.AuthURL, + TokenURL: cfg.TokenURL, + }, + } + + // Generate state for CSRF protection + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + state := base64.RawURLEncoding.EncodeToString(stateBytes) + + // Start local HTTP server for callback + listener, port, err := startLocalServer(cfg.CallbackPort) + if err != nil { + return nil, fmt.Errorf("failed to start local server: %w", err) + } + defer listener.Close() + + // Update redirect URL with actual port + oauth2Cfg.RedirectURL = fmt.Sprintf("http://localhost:%d/callback", port) + + // Channel to receive the authorization code + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Setup HTTP handler for callback + server := &http.Server{ + Handler: createCallbackHandler(state, codeChan, errChan), + 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) + } + }() + + // Build authorization URL with PKCE + authURL := oauth2Cfg.AuthCodeURL( + state, + oauth2.S256ChallengeOption(verifier), + ) + + // Display URL to user and try to open browser + fmt.Fprint(os.Stderr, "\n"+strings.Repeat("=", 80)+"\n") + fmt.Fprint(os.Stderr, "GitHub OAuth Authorization Required\n") + fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n") + fmt.Fprint(os.Stderr, "Opening your browser to complete authorization...\n\n") + fmt.Fprint(os.Stderr, "If your browser doesn't open automatically, please visit this URL:\n\n") + fmt.Fprintf(os.Stderr, " %s\n\n", authURL) + fmt.Fprint(os.Stderr, strings.Repeat("=", 80)+"\n\n") + + // Try to open browser + if err := openBrowser(authURL); err != nil { + log.Printf("Warning: Could not open browser automatically: %v", err) + } + + // Wait for callback with timeout + var code string + select { + case code = <-codeChan: + // Success + case err := <-errChan: + return nil, fmt.Errorf("callback error: %w", err) + case <-ctx.Done(): + return nil, fmt.Errorf("context cancelled: %w", ctx.Err()) + case <-time.After(DefaultAuthTimeout): + return nil, fmt.Errorf("authorization timeout after %v", DefaultAuthTimeout) + } + + // Shutdown server gracefully + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + + // Exchange authorization code for token with PKCE verifier + token, err := oauth2Cfg.Exchange( + ctx, + code, + oauth2.VerifierOption(verifier), + ) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + fmt.Fprint(os.Stderr, "\n✓ Authorization successful!\n\n") + + return &Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }, nil +} + +// 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") + fmt.Fprintf(w, ` + + + +Authorization Failed + + + +
+ + + +

Authorization Failed

+

%s

+

You can close this window.

+
+ +`, errMsg) + 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") + fmt.Fprint(w, ` + + + +Authorization Successful + + + +
+ + + +

Authorization Successful

+

You have successfully authorized the GitHub MCP Server.

+
+

You can close this window and retry your request.

+
+
+ +`) + }) + + 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..71c63173f --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,207 @@ +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) + assert.Equal(t, cfg.ClientID, mgr.config.ClientID) + assert.Equal(t, cfg.ClientSecret, mgr.config.ClientSecret) + assert.Equal(t, cfg.Scopes, mgr.config.Scopes) + 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 TestGenerateState(t *testing.T) { + state1, err := generateState() + require.NoError(t, err) + require.NotEmpty(t, state1) + + // State should be URL-safe base64 encoded + // 16 bytes of random data = ~22 chars in base64url + assert.GreaterOrEqual(t, len(state1), 20) + + // Each call should produce unique state + state2, err := generateState() + require.NoError(t, err) + assert.NotEqual(t, state1, state2) +} + +func TestGenerateElicitationID(t *testing.T) { + id1, err := generateElicitationID() + require.NoError(t, err) + require.NotEmpty(t, id1) + + // Each call should produce unique ID + id2, err := generateElicitationID() + require.NoError(t, err) + assert.NotEqual(t, id1, id2) +} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index fb4392fb9..1d09c4e5e 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -44,6 +44,7 @@ The following packages are included for the amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 564f20dcb..42518e475 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -44,6 +44,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6b4dcfb97..8a4a6463d 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -45,6 +45,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party/golang.org/x/oauth2/LICENSE b/third-party/golang.org/x/oauth2/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/oauth2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.