package github import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "time" ) const ( gitHubTokenFilePath = ".mcpregistry_github_token" // #nosec:G101 registryTokenFilePath = ".mcpregistry_registry_token" // #nosec:G101 // GitHub OAuth URLs GitHubDeviceCodeURL = "https://github.com/login/device/code" // #nosec:G101 GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101 ) // DeviceCodeResponse represents the response from GitHub's device code endpoint type DeviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } // AccessTokenResponse represents the response from GitHub's access token endpoint type AccessTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` Error string `json:"error,omitempty"` } // RegistryTokenResponse represents the response from registry's token exchange endpoint type RegistryTokenResponse struct { RegistryToken string `json:"registry_token"` ExpiresAt int64 `json:"expires_at"` } // StoredRegistryToken represents the registry token with expiration stored locally type StoredRegistryToken struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` } // OAuthProvider implements the AuthProvider interface using GitHub's device flow type OAuthProvider struct { clientID string forceLogin bool registryURL string } // ServerHealthResponse represents the response from the health endpoint type ServerHealthResponse struct { Status string `json:"status"` GitHubClientID string `json:"github_client_id"` } // NewOAuthProvider creates a new GitHub OAuth provider func NewOAuthProvider(forceLogin bool, registryURL string) *OAuthProvider { return &OAuthProvider{ forceLogin: forceLogin, registryURL: registryURL, } } // GetToken retrieves the registry JWT token (exchanges GitHub token if needed) func (g *OAuthProvider) GetToken(ctx context.Context) (string, error) { // Check if we have a valid registry token registryToken, err := readRegistryToken() if err == nil && registryToken != "" { return registryToken, nil } // If no valid registry token, exchange GitHub token for registry token githubToken, err := readToken() if err != nil { return "", fmt.Errorf("failed to read GitHub token: %w", err) } // Exchange GitHub token for registry token registryToken, expiresAt, err := g.exchangeTokenForRegistry(ctx, githubToken) if err != nil { return "", fmt.Errorf("failed to exchange token: %w", err) } // Store the registry token err = saveRegistryToken(registryToken, expiresAt) if err != nil { return "", fmt.Errorf("failed to save registry token: %w", err) } return registryToken, nil } // NeedsLogin checks if a new login is required func (g *OAuthProvider) NeedsLogin() bool { if g.forceLogin { return true } // Check if GitHub token exists _, statErr := os.Stat(gitHubTokenFilePath) if os.IsNotExist(statErr) { return true } // Check if valid registry token exists _, err := readRegistryToken() if err != nil { // No valid registry token, but we have GitHub token // We don't need to login, just exchange tokens return false } return false } // Login performs the GitHub device flow authentication func (g *OAuthProvider) Login(ctx context.Context) error { // If clientID is not set, try to retrieve it from the server's health endpoint if g.clientID == "" { clientID, err := getClientID(ctx, g.registryURL) if err != nil { return fmt.Errorf("error getting GitHub Client ID: %w", err) } g.clientID = clientID } // Device flow login logic using GitHub's device flow // First, request a device code deviceCode, userCode, verificationURI, err := g.requestDeviceCode(ctx) if err != nil { return fmt.Errorf("error requesting device code: %w", err) } // Display instructions to the user log.Println("\nTo authenticate, please:") log.Println("1. Go to:", verificationURI) log.Println("2. Enter code:", userCode) log.Println("3. Authorize this application") // Poll for the token log.Println("Waiting for authorization...") token, err := g.pollForToken(ctx, deviceCode) if err != nil { return fmt.Errorf("error polling for token: %w", err) } // Store the token locally err = saveToken(token) if err != nil { return fmt.Errorf("error saving token: %w", err) } log.Println("Successfully authenticated!") return nil } // Name returns the name of this auth provider func (g *OAuthProvider) Name() string { return "github" } // requestDeviceCode initiates the device authorization flow func (g *OAuthProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) { if g.clientID == "" { return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login") } payload := map[string]string{ "client_id": g.clientID, "scope": "read:org read:user", } jsonData, err := json.Marshal(payload) if err != nil { return "", "", "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData)) if err != nil { return "", "", "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", "", "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", "", "", err } if resp.StatusCode != http.StatusOK { return "", "", "", fmt.Errorf("request device code failed: %s", body) } var deviceCodeResp DeviceCodeResponse err = json.Unmarshal(body, &deviceCodeResp) if err != nil { return "", "", "", err } return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil } // pollForToken polls for access token after user completes authorization func (g *OAuthProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) { if g.clientID == "" { return "", fmt.Errorf("GitHub Client ID is required for device flow login") } payload := map[string]string{ "client_id": g.clientID, "device_code": deviceCode, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", } jsonData, err := json.Marshal(payload) if err != nil { return "", err } // Default polling interval and expiration time interval := 5 // seconds expiresIn := 900 // 15 minutes deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) for time.Now().Before(deadline) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", err } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return "", err } var tokenResp AccessTokenResponse err = json.Unmarshal(body, &tokenResp) if err != nil { return "", err } if tokenResp.Error == "authorization_pending" { // User hasn't authorized yet, wait and retry time.Sleep(time.Duration(interval) * time.Second) continue } if tokenResp.Error != "" { return "", fmt.Errorf("token request failed: %s", tokenResp.Error) } if tokenResp.AccessToken != "" { return tokenResp.AccessToken, nil } // If we reach here, something unexpected happened return "", fmt.Errorf("failed to obtain access token") } return "", fmt.Errorf("device code authorization timed out") } // saveToken saves the GitHub access token to a local file func saveToken(token string) error { return os.WriteFile(gitHubTokenFilePath, []byte(token), 0600) } // readToken reads the GitHub access token from a local file func readToken() (string, error) { tokenData, err := os.ReadFile(gitHubTokenFilePath) if err != nil { return "", err } return string(tokenData), nil } func getClientID(ctx context.Context, registryURL string) (string, error) { // This function should retrieve the GitHub Client ID from the registry URL // For now, we will return a placeholder value // In a real implementation, this would likely involve querying the registry or configuration if registryURL == "" { return "", fmt.Errorf("registry URL is required to get GitHub Client ID") } // get the clientID from the server's health endpoint healthURL := registryURL + "/v0/health" req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) if err != nil { log.Printf("Error creating request: %s\n", err.Error()) return "", err } client := &http.Client{} resp, err := client.Do(req) if err != nil { log.Printf("Error fetching health endpoint: %s\n", err.Error()) return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) log.Printf("Health endpoint returned status %d: %s\n", resp.StatusCode, body) return "", fmt.Errorf("health endpoint returned status %d: %s", resp.StatusCode, body) } var healthResponse ServerHealthResponse err = json.NewDecoder(resp.Body).Decode(&healthResponse) if err != nil { log.Printf("Error decoding health response: %s\n", err.Error()) return "", err } if healthResponse.GitHubClientID == "" { log.Println("GitHub Client ID is not set in the server's health response.") return "", fmt.Errorf("GitHub Client ID is not set in the server's health response") } githubClientID := healthResponse.GitHubClientID return githubClientID, nil } // exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token func (g *OAuthProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) { if g.registryURL == "" { return "", 0, fmt.Errorf("registry URL is required for token exchange") } // Prepare the request body payload := map[string]string{ "github_token": githubToken, } jsonData, err := json.Marshal(payload) if err != nil { return "", 0, fmt.Errorf("failed to marshal request: %w", err) } // Make the token exchange request exchangeURL := g.registryURL + "/v0/auth/github" req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData)) if err != nil { return "", 0, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", 0, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", 0, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return "", 0, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body) } var tokenResp RegistryTokenResponse err = json.Unmarshal(body, &tokenResp) if err != nil { return "", 0, fmt.Errorf("failed to unmarshal response: %w", err) } return tokenResp.RegistryToken, tokenResp.ExpiresAt, nil } // saveRegistryToken saves the registry JWT token to a local file with expiration func saveRegistryToken(token string, expiresAt int64) error { storedToken := StoredRegistryToken{ Token: token, ExpiresAt: expiresAt, } data, err := json.Marshal(storedToken) if err != nil { return fmt.Errorf("failed to marshal token: %w", err) } return os.WriteFile(registryTokenFilePath, data, 0600) } // readRegistryToken reads the registry JWT token from a local file func readRegistryToken() (string, error) { data, err := os.ReadFile(registryTokenFilePath) if err != nil { return "", err } var storedToken StoredRegistryToken err = json.Unmarshal(data, &storedToken) if err != nil { return "", fmt.Errorf("failed to unmarshal token: %w", err) } // Check if token has expired if time.Now().Unix() >= storedToken.ExpiresAt { // Token has expired, remove the file os.Remove(registryTokenFilePath) return "", fmt.Errorf("registry token has expired") } return storedToken.Token, nil }