diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..0f2f58086 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# GitHub MCP Server - OAuth Configuration +# +# To use OAuth authentication: +# 1. Copy this file to .env: cp .env.example .env +# 2. Create a GitHub OAuth App at: https://github.com/settings/developers +# - Click "New OAuth App" +# - Application name: github-mcp-server (personal) +# - Homepage URL: https://github.com/github/github-mcp-server +# - Authorization callback URL: http://localhost:8080/callback +# 3. Copy your Client ID and Client Secret below +# 4. Run: source .env && github-mcp-server stdio --oauth + +# OAuth App Client ID (public identifier) +GITHUB_OAUTH_CLIENT_ID=your_client_id_here + +# OAuth App Client Secret (keep this secret!) +GITHUB_OAUTH_CLIENT_SECRET=your_client_secret_here + +# Optional: Custom token storage path +# GITHUB_OAUTH_TOKEN_PATH=~/.config/github-mcp-server/tokens.json + +# Optional: Custom callback port +# GITHUB_OAUTH_CALLBACK_PORT=8080 diff --git a/.gitignore b/.gitignore index eedf65165..e267599ff 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ e2e.test .history conformance-report/ + +# Environment variables (contains OAuth secrets) +.env +.env.local +.env.*.local diff --git a/README.md b/README.md index 975175c61..c62b1b349 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,9 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. -3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). +3. Choose your authentication method: + - **OAuth (Recommended)**: More secure with automatic token refresh. See [OAuth Authentication](#oauth-authentication-local-server) section below. + - **Personal Access Token (PAT)**: [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). Enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).
Handling PATs Securely @@ -238,6 +239,81 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
+### OAuth Authentication (Local Server) + +The local MCP server supports OAuth 2.0 authentication for a more secure and user-friendly experience compared to Personal Access Tokens. + +#### Benefits of OAuth + +- **Automatic token refresh**: No need to manually regenerate expired tokens +- **Browser-based authentication**: Familiar GitHub login flow +- **Revocable access**: Easy to revoke from GitHub settings +- **Scoped permissions**: Only request permissions your tools need + +#### Setup + +1. **Create a GitHub OAuth App**: + - Go to https://github.com/settings/developers + - Click "New OAuth App" + - **Application name**: `github-mcp-server (personal)` + - **Homepage URL**: `https://github.com/github/github-mcp-server` + - **Authorization callback URL**: `http://localhost:8080/callback` + - Click "Register application" + - Copy the **Client ID** and generate a **Client Secret** + +2. **Configure environment variables**: + ```bash + # Copy the example file + cp .env.example .env + + # Edit .env and add your credentials: + # GITHUB_OAUTH_CLIENT_ID=your_client_id_here + # GITHUB_OAUTH_CLIENT_SECRET=your_client_secret_here + ``` + +3. **Start the server with OAuth**: + ```bash + # Load environment variables + source .env + + # Start with OAuth authentication + github-mcp-server stdio --oauth + ``` + +4. **Authenticate**: + - A browser window will open automatically + - Log in to GitHub and authorize the application + - Return to the terminal - the server will start automatically + +#### Token Storage + +OAuth tokens are securely stored at `~/.config/github-mcp-server/tokens.json` with restricted file permissions (0600). The server automatically refreshes expired tokens. + +#### Re-authentication + +If you need to re-authenticate (e.g., to grant new scopes): + +```bash +source .env +github-mcp-server stdio --oauth --reauth +``` + +#### Configuration Options + +| Flag | Environment Variable | Default | +|------|---------------------|---------| +| `--oauth` | `GITHUB_OAUTH` | `false` | +| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | Required | +| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Required | +| `--oauth-token-path` | `GITHUB_OAUTH_TOKEN_PATH` | `~/.config/github-mcp-server/tokens.json` | +| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | `8080` | + +#### Revoking Access + +To revoke the OAuth app's access, visit: https://github.com/settings/applications + +--- + ### 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 c361a4d5a..3e7c28638 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "os" @@ -8,7 +9,9 @@ import ( "time" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/pkg/auth" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/tokenmanager" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -32,11 +35,111 @@ 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") + // Determine authentication method + pat := viper.GetString("personal_access_token") + useOAuth := viper.GetBool("oauth") + + var finalToken string + var tokenManager *tokenmanager.Manager + + if pat != "" { + // Backward compatibility: PAT takes precedence if set + finalToken = pat + } else if useOAuth { + // OAuth authentication flow + clientID := viper.GetString("oauth_client_id") + if clientID == "" { + clientID = auth.DefaultClientID + if clientID == "" { + return errors.New("OAuth client ID not configured. Please set GITHUB_OAUTH_CLIENT_ID or use --oauth-client-id flag. See .env.example for setup instructions.") + } + } + clientSecret := viper.GetString("oauth_client_secret") + if clientSecret == "" { + clientSecret = auth.DefaultClientSecret + if clientSecret == "" { + return errors.New("OAuth client secret not configured. Please set GITHUB_OAUTH_CLIENT_SECRET or use --oauth-client-secret flag. See .env.example for setup instructions.") + } + } + tokenPath := viper.GetString("oauth_token_path") + if tokenPath == "" { + tokenPath = auth.GetDefaultTokenPath() + } + + // Initialize token storage + storage, err := auth.NewTokenStorage(tokenPath) + if err != nil { + return fmt.Errorf("failed to init token storage: %w", err) + } + + host := viper.GetString("host") + if host == "" { + host = "github.com" + } + + // Load existing token + var token *auth.Token + if !viper.GetBool("reauth") { + token, err = storage.Load(host) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load token: %v\n", err) + } + } + + // If no token or invalid, start OAuth flow + if token == nil || !token.IsValid() { + fmt.Fprintf(os.Stderr, "Starting OAuth authentication...\n") + + // Calculate required scopes + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + + scopes := auth.GetRequiredScopes(enabledToolsets) + + oauthConfig := &auth.OAuthConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", viper.GetInt("oauth_callback_port")), + AuthURL: getAuthURL(host), + TokenURL: getTokenURL(host), + } + + flow := auth.NewOAuthFlow(oauthConfig) + token, err = flow.StartFlow(context.Background(), viper.GetInt("oauth_callback_port")) + if err != nil { + return fmt.Errorf("OAuth authentication failed: %w", err) + } + + if err := storage.Save(host, token); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save token: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "✓ Authentication successful!\n") + } + + // Create token manager + tokenManager = tokenmanager.NewManager(storage, &auth.OAuthConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + AuthURL: getAuthURL(host), + TokenURL: getTokenURL(host), + }, host, token) + + // Get valid token (refresh if necessary) + finalToken, err = tokenManager.GetValidToken(context.Background()) + if err != nil { + return fmt.Errorf("failed to get valid token: %w", err) + } + } else { + return errors.New("no authentication configured: set GITHUB_PERSONAL_ACCESS_TOKEN or use --oauth") } + // Parse toolsets, tools, and features // 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. @@ -50,9 +153,7 @@ var ( return fmt.Errorf("failed to unmarshal toolsets: %w", err) } } - // else: enabledToolsets stays nil, meaning "use defaults" - // Parse tools (similar to toolsets) var enabledTools []string if viper.IsSet("tools") { if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { @@ -60,7 +161,6 @@ var ( } } - // Parse enabled features (similar to toolsets) var enabledFeatures []string if viper.IsSet("features") { if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { @@ -72,7 +172,8 @@ var ( stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), - Token: token, + Token: finalToken, + TokenManager: tokenManager, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, @@ -112,6 +213,14 @@ func init() { rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // OAuth authentication flags + rootCmd.PersistentFlags().Bool("oauth", false, "Use OAuth authentication instead of PAT") + rootCmd.PersistentFlags().String("oauth-client-id", "", "OAuth App client ID (uses default if empty)") + rootCmd.PersistentFlags().String("oauth-client-secret", "", "OAuth App client secret (uses default if empty)") + rootCmd.PersistentFlags().String("oauth-token-path", "", "Path to OAuth token storage") + rootCmd.PersistentFlags().Int("oauth-callback-port", 8080, "Local port for OAuth callback") + rootCmd.PersistentFlags().Bool("reauth", false, "Force re-authentication (use with --oauth)") + // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) @@ -126,6 +235,12 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("oauth", rootCmd.PersistentFlags().Lookup("oauth")) + _ = 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_token_path", rootCmd.PersistentFlags().Lookup("oauth-token-path")) + _ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port")) + _ = viper.BindPFlag("reauth", rootCmd.PersistentFlags().Lookup("reauth")) // Add subcommands rootCmd.AddCommand(stdioCmd) @@ -153,3 +268,19 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { } return pflag.NormalizedName(name) } + +// getAuthURL returns the OAuth authorization URL for the given host +func getAuthURL(host string) string { + if host == "" || host == "github.com" { + return "https://github.com/login/oauth/authorize" + } + return fmt.Sprintf("https://%s/login/oauth/authorize", host) +} + +// getTokenURL returns the OAuth token URL for the given host +func getTokenURL(host string) string { + if host == "" || host == "github.com" { + return "https://github.com/login/oauth/access_token" + } + return fmt.Sprintf("https://%s/login/oauth/access_token", host) +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 37aabb0a6..7ca63341a 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -20,6 +20,7 @@ import ( mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/tokenmanager" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -36,6 +37,10 @@ type MCPServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // TokenManager manages OAuth token lifecycle (refresh, etc.) + // Optional - only used when OAuth authentication is enabled + TokenManager *tokenmanager.Manager + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -95,16 +100,35 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, restClient.BaseURL = apiHost.baseRESTURL restClient.UploadURL = apiHost.uploadURL + // If OAuth token manager is available, wrap the HTTP client + if cfg.TokenManager != nil { + wrappedClient := cfg.TokenManager.WrapHTTPClient(restClient.Client()) + restClient = gogithub.NewClient(wrappedClient).WithAuthToken(cfg.Token) + restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + restClient.BaseURL = apiHost.baseRESTURL + restClient.UploadURL = apiHost.uploadURL + } + // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host - gqlHTTPClient := &http.Client{ - Transport: &bearerAuthTransport{ - transport: &github.GraphQLFeaturesTransport{ - Transport: http.DefaultTransport, - }, - token: cfg.Token, + baseTransport := &bearerAuthTransport{ + transport: &github.GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, }, + token: cfg.Token, } + + var gqlHTTPClient *http.Client + if cfg.TokenManager != nil { + gqlHTTPClient = cfg.TokenManager.WrapHTTPClient(&http.Client{ + Transport: baseTransport, + }) + } else { + gqlHTTPClient = &http.Client{ + Transport: baseTransport, + } + } + gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) @@ -296,6 +320,10 @@ type StdioServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // TokenManager manages OAuth token lifecycle (refresh, etc.) + // Optional - only used when OAuth authentication is enabled + TokenManager *tokenmanager.Manager + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -382,6 +410,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, + TokenManager: cfg.TokenManager, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, diff --git a/pkg/auth/defaults.go b/pkg/auth/defaults.go new file mode 100644 index 000000000..5daa488c3 --- /dev/null +++ b/pkg/auth/defaults.go @@ -0,0 +1,56 @@ +package auth + +import ( + "os" + "path/filepath" +) + +const ( + // DefaultClientID is empty - users must provide their own OAuth App credentials + // Set via GITHUB_OAUTH_CLIENT_ID environment variable or --oauth-client-id flag + // See .env.example for setup instructions + DefaultClientID = "" + + // DefaultClientSecret is empty - users must provide their own OAuth App credentials + // Set via GITHUB_OAUTH_CLIENT_SECRET environment variable or --oauth-client-secret flag + // See .env.example for setup instructions + DefaultClientSecret = "" + + // DefaultCallbackPort is the default port for OAuth callback + DefaultCallbackPort = 8080 +) + +// GetDefaultTokenPath returns the default token storage path +func GetDefaultTokenPath() string { + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + home, _ := os.UserHomeDir() + configDir = filepath.Join(home, ".config") + } + return filepath.Join(configDir, "github-mcp-server", "tokens.json") +} + +// GetRequiredScopes returns OAuth scopes required for given toolsets +// This determines what permissions the OAuth token needs based on enabled features +func GetRequiredScopes(toolsets []string) []string { + // Default scopes needed for basic functionality + scopes := []string{"repo", "user", "read:org"} + + // Additional scopes based on toolsets + toolsetMap := make(map[string]bool) + for _, ts := range toolsets { + toolsetMap[ts] = true + } + + // Add workflow scope if actions toolset is enabled + if toolsetMap["actions"] { + scopes = append(scopes, "workflow") + } + + // Add admin scopes if admin toolset is enabled + if toolsetMap["admin"] { + scopes = append(scopes, "admin:org", "admin:repo_hook") + } + + return scopes +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go new file mode 100644 index 000000000..eece8a826 --- /dev/null +++ b/pkg/auth/oauth.go @@ -0,0 +1,375 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// OAuthConfig holds OAuth App configuration +type OAuthConfig struct { + ClientID string + ClientSecret string + Scopes []string + RedirectURL string + AuthURL string + TokenURL string +} + +// OAuthFlow handles the authorization code flow with PKCE +type OAuthFlow struct { + config *OAuthConfig + oauth2Config *oauth2.Config +} + +// NewOAuthFlow creates a new OAuth flow handler +func NewOAuthFlow(config *OAuthConfig) *OAuthFlow { + oauth2Config := &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + Scopes: config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + }, + RedirectURL: config.RedirectURL, + } + + return &OAuthFlow{ + config: config, + oauth2Config: oauth2Config, + } +} + +// StartFlow initiates the OAuth flow +// 1. Generates PKCE parameters +// 2. Starts local callback server +// 3. Opens browser to authorization URL +// 4. Waits for callback +// 5. Exchanges code for tokens +func (f *OAuthFlow) StartFlow(ctx context.Context, port int) (*Token, error) { + // Generate PKCE parameters + codeVerifier, codeChallenge, err := generatePKCEParams() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE params: %w", err) + } + + // Generate random state for CSRF protection + state, err := generateRandomString(32) + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + + // Start callback server + codeChan, server, err := f.startCallbackServer(ctx, port, state) + if err != nil { + return nil, fmt.Errorf("failed to start callback server: %w", err) + } + defer server.Close() + + // Build authorization URL with PKCE + authURL := f.oauth2Config.AuthCodeURL(state, + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + // Open browser + fmt.Printf("\nOpening browser for GitHub authorization...\n") + fmt.Printf("If browser doesn't open, visit: %s\n\n", authURL) + if err := openBrowser(authURL); err != nil { + fmt.Printf("Failed to open browser automatically: %v\n", err) + } + + // Wait for callback with timeout + select { + case code := <-codeChan: + if code == "" { + return nil, errors.New("authorization cancelled or failed") + } + // Exchange code for token + return f.exchangeCode(ctx, code, codeVerifier) + case <-time.After(5 * time.Minute): + return nil, errors.New("authorization timeout after 5 minutes") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// generatePKCEParams creates PKCE code_verifier and code_challenge +func generatePKCEParams() (verifier, challenge string, err error) { + // Generate random verifier (43-128 characters) + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(verifierBytes) + + // Generate challenge = BASE64URL(SHA256(verifier)) + h := sha256.New() + h.Write([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return verifier, challenge, nil +} + +// generateRandomString generates a cryptographically secure random string +func generateRandomString(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// startCallbackServer starts local HTTP server for OAuth callback +func (f *OAuthFlow) startCallbackServer(ctx context.Context, port int, state string) (<-chan string, *http.Server, error) { + codeChan := make(chan string, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + // Verify state + if r.URL.Query().Get("state") != state { + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + codeChan <- "" + return + } + + // Check for errors + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + errorDesc := r.URL.Query().Get("error_description") + http.Error(w, fmt.Sprintf("Authorization error: %s - %s", errMsg, errorDesc), http.StatusBadRequest) + codeChan <- "" + return + } + + // Get authorization code + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Missing authorization code", http.StatusBadRequest) + codeChan <- "" + return + } + + // Send success response + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + + + + Authorization Successful + + + +
+
+

Authorization Successful!

+

You can close this window and return to the terminal.

+
+ + +`) + + codeChan <- code + }) + + // Create server + server := &http.Server{ + Addr: fmt.Sprintf("127.0.0.1:%d", port), + Handler: mux, + } + + // Start server in background + listener, err := net.Listen("tcp", server.Addr) + if err != nil { + return nil, nil, fmt.Errorf("failed to listen on port %d: %w", port, err) + } + + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + fmt.Printf("Callback server error: %v\n", err) + } + }() + + return codeChan, server, nil +} + +// exchangeCode exchanges authorization code for tokens +func (f *OAuthFlow) exchangeCode(ctx context.Context, code, codeVerifier string) (*Token, error) { + // Exchange code for token with PKCE verifier + oauth2Token, err := f.oauth2Config.Exchange(ctx, code, + oauth2.SetAuthURLParam("code_verifier", codeVerifier), + ) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + // Convert oauth2.Token to our Token type + token := &Token{ + AccessToken: oauth2Token.AccessToken, + RefreshToken: oauth2Token.RefreshToken, + TokenType: oauth2Token.TokenType, + ExpiresAt: oauth2Token.Expiry, + CreatedAt: time.Now(), + } + + // Get scopes from token response + // GitHub returns scopes in the X-OAuth-Scopes header or as part of token metadata + if scopesRaw := oauth2Token.Extra("scope"); scopesRaw != nil { + if scopeStr, ok := scopesRaw.(string); ok { + token.Scopes = strings.Split(scopeStr, ",") + // Trim whitespace + for i := range token.Scopes { + token.Scopes[i] = strings.TrimSpace(token.Scopes[i]) + } + } + } + + // If scopes are empty, use the requested scopes + if len(token.Scopes) == 0 { + token.Scopes = f.config.Scopes + } + + return token, nil +} + +// RefreshToken refreshes an OAuth token using the refresh token +func RefreshToken(ctx context.Context, config *OAuthConfig, refreshToken string) (*Token, error) { + oauth2Config := &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + }, + } + + // Create a token source with the refresh token + token := &oauth2.Token{ + RefreshToken: refreshToken, + } + + tokenSource := oauth2Config.TokenSource(ctx, token) + newToken, err := tokenSource.Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + // Convert to our Token type + result := &Token{ + AccessToken: newToken.AccessToken, + RefreshToken: newToken.RefreshToken, + TokenType: newToken.TokenType, + ExpiresAt: newToken.Expiry, + CreatedAt: time.Now(), + } + + // Get scopes + if scopesRaw := newToken.Extra("scope"); scopesRaw != nil { + if scopeStr, ok := scopesRaw.(string); ok { + result.Scopes = strings.Split(scopeStr, ",") + for i := range result.Scopes { + result.Scopes[i] = strings.TrimSpace(result.Scopes[i]) + } + } + } + + return result, nil +} + +// RevokeToken revokes an OAuth token +func RevokeToken(ctx context.Context, config *OAuthConfig, token string) error { + // GitHub's token revocation endpoint + revokeURL := strings.Replace(config.TokenURL, "/access_token", "/revoke", 1) + + data := url.Values{} + data.Set("access_token", token) + + req, err := http.NewRequestWithContext(ctx, "DELETE", revokeURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(config.ClientID, config.ClientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to revoke token: %s", string(body)) + } + + return nil +} + +// ValidateToken validates an OAuth token by making a request to GitHub API +func ValidateToken(ctx context.Context, token, apiURL string) error { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL+"/user", nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + var errorResp struct { + Message string `json:"message"` + } + _ = json.Unmarshal(body, &errorResp) + return fmt.Errorf("token validation failed: %s", errorResp.Message) + } + + return nil +} + +// openBrowser opens the default browser to the given URL +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + default: // linux, freebsd, openbsd, netbsd + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} diff --git a/pkg/auth/token_storage.go b/pkg/auth/token_storage.go new file mode 100644 index 000000000..ecfe87c2b --- /dev/null +++ b/pkg/auth/token_storage.go @@ -0,0 +1,193 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Token represents an OAuth token with metadata +type Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"expires_at"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` +} + +// IsValid checks if a token is still valid (not expired) +// Uses a 5-minute buffer to refresh tokens before they expire +func (t *Token) IsValid() bool { + if t.AccessToken == "" { + return false + } + + // OAuth App tokens don't have refresh tokens and never expire + // Only check expiration if we have a refresh token (GitHub App scenario) + if t.RefreshToken == "" { + return true + } + + // For GitHub Apps with refresh tokens, check if token expires in more than 5 minutes + return time.Until(t.ExpiresAt) > 5*time.Minute +} + +// tokenFile represents the JSON structure stored on disk +type tokenFile struct { + Version string `json:"version"` + DefaultHost string `json:"default_host"` + Tokens map[string]*Token `json:"tokens"` +} + +// TokenStorage manages persistent token storage +type TokenStorage struct { + filePath string + mu sync.RWMutex +} + +// NewTokenStorage creates a token storage instance +// Creates the directory if it doesn't exist +func NewTokenStorage(path string) (*TokenStorage, error) { + // Ensure directory exists with secure permissions + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("failed to create token directory: %w", err) + } + + return &TokenStorage{ + filePath: path, + }, nil +} + +// Load reads token from disk for a specific host +func (s *TokenStorage) Load(host string) (*Token, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Check if file exists + if _, err := os.Stat(s.filePath); os.IsNotExist(err) { + return nil, errors.New("token file does not exist") + } + + // Check file permissions - must be 0600 or more restrictive + info, err := os.Stat(s.filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat token file: %w", err) + } + mode := info.Mode().Perm() + if mode&0077 != 0 { + return nil, fmt.Errorf("token file has insecure permissions %o (should be 0600)", mode) + } + + // Read file + data, err := os.ReadFile(s.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read token file: %w", err) + } + + // Parse JSON + var tf tokenFile + if err := json.Unmarshal(data, &tf); err != nil { + return nil, fmt.Errorf("failed to parse token file: %w", err) + } + + // Get token for host + token, ok := tf.Tokens[host] + if !ok { + return nil, fmt.Errorf("no token found for host %s", host) + } + + return token, nil +} + +// Save writes token to disk for a specific host +// Uses atomic write (write to temp, then rename) +func (s *TokenStorage) Save(host string, token *Token) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Load existing file or create new + var tf tokenFile + if data, err := os.ReadFile(s.filePath); err == nil { + if err := json.Unmarshal(data, &tf); err != nil { + // If file is corrupted, back it up and start fresh + backupPath := s.filePath + ".bak" + _ = os.Rename(s.filePath, backupPath) + tf = tokenFile{ + Version: "1", + DefaultHost: host, + Tokens: make(map[string]*Token), + } + } + } else { + tf = tokenFile{ + Version: "1", + DefaultHost: host, + Tokens: make(map[string]*Token), + } + } + + // Update token + tf.Tokens[host] = token + + // Marshal to JSON + data, err := json.MarshalIndent(tf, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + + // Atomic write: write to temp file, then rename + tempPath := s.filePath + ".tmp" + if err := os.WriteFile(tempPath, data, 0600); err != nil { + return fmt.Errorf("failed to write temp token file: %w", err) + } + + if err := os.Rename(tempPath, s.filePath); err != nil { + _ = os.Remove(tempPath) // Clean up temp file + return fmt.Errorf("failed to rename token file: %w", err) + } + + return nil +} + +// Delete removes token for a specific host +func (s *TokenStorage) Delete(host string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Read existing file + data, err := os.ReadFile(s.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil // Already deleted + } + return fmt.Errorf("failed to read token file: %w", err) + } + + // Parse JSON + var tf tokenFile + if err := json.Unmarshal(data, &tf); err != nil { + return fmt.Errorf("failed to parse token file: %w", err) + } + + // Delete token + delete(tf.Tokens, host) + + // If no tokens left, delete the file + if len(tf.Tokens) == 0 { + return os.Remove(s.filePath) + } + + // Save updated file + data, err = json.MarshalIndent(tf, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + + return os.WriteFile(s.filePath, data, 0600) +} diff --git a/pkg/tokenmanager/manager.go b/pkg/tokenmanager/manager.go new file mode 100644 index 000000000..e6f3867f4 --- /dev/null +++ b/pkg/tokenmanager/manager.go @@ -0,0 +1,143 @@ +package tokenmanager + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "github.com/github/github-mcp-server/pkg/auth" +) + +// Manager handles token refresh and lifecycle +type Manager struct { + storage *auth.TokenStorage + oauthConfig *auth.OAuthConfig + host string + + mu sync.RWMutex + currentToken *auth.Token +} + +// NewManager creates a token manager +func NewManager(storage *auth.TokenStorage, oauthCfg *auth.OAuthConfig, host string, initialToken *auth.Token) *Manager { + return &Manager{ + storage: storage, + oauthConfig: oauthCfg, + host: host, + currentToken: initialToken, + } +} + +// GetValidToken returns a valid token, refreshing if necessary +// This method is safe for concurrent use +func (m *Manager) GetValidToken(ctx context.Context) (string, error) { + m.mu.RLock() + token := m.currentToken + m.mu.RUnlock() + + // Check if token is valid + if token != nil && token.IsValid() { + return token.AccessToken, nil + } + + // Token needs refresh + return m.refreshToken(ctx) +} + +// refreshToken refreshes the OAuth token +// Uses locking to prevent duplicate refresh attempts +func (m *Manager) refreshToken(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Double-check if another goroutine already refreshed + if m.currentToken != nil && m.currentToken.IsValid() { + return m.currentToken.AccessToken, nil + } + + // If no token at all, we can't proceed + if m.currentToken == nil { + return "", fmt.Errorf("no token available") + } + + // OAuth App tokens don't have refresh tokens and don't expire + // Just return the current access token + if m.currentToken.RefreshToken == "" { + return m.currentToken.AccessToken, nil + } + + // GitHub App path: refresh using refresh token + // Attempt refresh with exponential backoff + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + // Exponential backoff: 1s, 2s, 4s + backoff := time.Duration(1<