// Package oauth implements the user-facing OAuth 2.1 login flows the stdio // server uses to obtain a GitHub token without a pre-provisioned Personal // Access Token. // // It supports both GitHub OAuth Apps and GitHub Apps (user-to-server). The // only practical difference is that GitHub App user tokens expire and carry a // refresh token; this package always returns a refreshing [golang.org/x/oauth2.TokenSource] // so callers never have to special-case the app type. // // The package depends only on golang.org/x/oauth2 and the standard library. MCP // concerns (sessions, elicitation) are abstracted behind the [Prompter] // interface so the flows can be tested without a live client. package oauth import ( "crypto/rand" "encoding/base64" "fmt" "strings" "golang.org/x/oauth2" ) // Config describes an OAuth client and the GitHub endpoints it talks to. type Config struct { ClientID string ClientSecret string // Scopes requested during authorization. GitHub Apps ignore these (their // access is governed by installed permissions); OAuth Apps honor them. Scopes []string // Endpoint holds the authorization, token, and device endpoints. Build one // with [GitHubEndpoint]. Endpoint oauth2.Endpoint // CallbackPort is the fixed local port for the PKCE callback server. Zero // requests a random port, which is the secure default for native binaries // but cannot be reached through Docker port mapping (see the Manager). CallbackPort int } // NewGitHubConfig builds a Config for the given GitHub host. An empty host // targets github.com; otherwise the host may be a GHES or ghe.com hostname, // with or without a scheme. func NewGitHubConfig(clientID, clientSecret string, scopes []string, host string, callbackPort int) Config { return Config{ ClientID: clientID, ClientSecret: clientSecret, Scopes: scopes, Endpoint: GitHubEndpoint(host), CallbackPort: callbackPort, } } // GitHubEndpoint returns the OAuth authorization, token, and device endpoints // for a GitHub host. An empty host targets github.com. func GitHubEndpoint(host string) oauth2.Endpoint { base := NormalizeHost(host) return oauth2.Endpoint{ AuthURL: base + "/login/oauth/authorize", TokenURL: base + "/login/oauth/access_token", DeviceAuthURL: base + "/login/device/code", } } // NormalizeHost turns a user-supplied host into a scheme+host base URL with no // trailing slash. The API subdomain is stripped because OAuth endpoints live on // the web host, not the API host (api.github.com -> github.com). An empty host // yields the github.com default, so callers can also use it to recognize the // default host (NormalizeHost(host) == "https://github.com"). func NormalizeHost(host string) string { host = strings.TrimSpace(host) if host == "" { return "https://github.com" } scheme := "https" switch { case strings.HasPrefix(host, "https://"): host = strings.TrimPrefix(host, "https://") case strings.HasPrefix(host, "http://"): scheme = "http" host = strings.TrimPrefix(host, "http://") } // Drop any path, query, or fragment; we only need scheme://host. if i := strings.IndexAny(host, "/?#"); i >= 0 { host = host[:i] } host = strings.TrimPrefix(host, "api.") return fmt.Sprintf("%s://%s", scheme, host) } // randomState returns a cryptographically random URL-safe string used as the // OAuth state parameter (CSRF protection) and elicitation IDs. func randomState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generating random state: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil }