registry/internal/auth/github.go

178 lines
5.6 KiB
Go

// Package auth provides authentication mechanisms for the MCP registry
package auth
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
)
var (
// ErrAuthFailed is returned when authentication fails
ErrAuthFailed = errors.New("authentication failed")
// ErrInvalidToken is returned when a token is invalid
ErrInvalidToken = errors.New("invalid token")
// ErrMissingScope is returned when a token doesn't have the required scope
ErrMissingScope = errors.New("token missing required scope")
)
// GitHubOAuthConfig holds the configuration for GitHub OAuth
type GitHubOAuthConfig struct {
ClientID string
ClientSecret string
}
// 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"`
}
// TokenValidationResponse represents the response from GitHub's token validation endpoint
type TokenValidationResponse struct {
ID int `json:"id"`
URL string `json:"url"`
Scopes []string `json:"scopes"`
SingleFile string `json:"single_file,omitempty"`
Repository string `json:"repository,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Error string `json:"error,omitempty"`
}
// GitHubDeviceAuth provides methods for GitHub device OAuth authentication
type GitHubDeviceAuth struct {
config GitHubOAuthConfig
}
// NewGitHubDeviceAuth creates a new GitHub device auth instance
func NewGitHubDeviceAuth(config GitHubOAuthConfig) *GitHubDeviceAuth {
return &GitHubDeviceAuth{
config: config,
}
}
// ValidateToken checks if a token has write:packages access to the required repository
// and verifies that the user matches the owner in the required repository
func (g *GitHubDeviceAuth) ValidateToken(token string, requiredRepo string) (bool, error) {
// If no repo is required, we can't validate properly
if requiredRepo == "" {
return false, fmt.Errorf("repository reference is required for token validation")
}
// Get the authenticated user
userReq, err := http.NewRequest("GET", "https://api.github.com/user", nil)
if err != nil {
return false, err
}
userReq.Header.Set("Accept", "application/vnd.github+json")
userReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{}
userResp, err := client.Do(userReq)
if err != nil {
return false, err
}
defer userResp.Body.Close()
if userResp.StatusCode != http.StatusOK {
return false, fmt.Errorf("failed to get user info: status %d", userResp.StatusCode)
}
var userInfo struct {
Login string `json:"login"`
}
userBody, err := io.ReadAll(userResp.Body)
if err != nil {
return false, err
}
if err := json.Unmarshal(userBody, &userInfo); err != nil {
return false, err
}
// Extract owner from the required repo
owner, _, err := g.ExtractGitHubRepoFromName(requiredRepo)
if err != nil {
return false, err
}
// Verify that the authenticated user matches the owner
if userInfo.Login != owner {
// Check if the user is a member of the organization
isMember, err := g.checkOrgMembership(token, userInfo.Login, owner)
if err != nil {
return false, fmt.Errorf("failed to check org membership: %s", owner)
}
if !isMember {
return false, fmt.Errorf("token belongs to user %s, but repository is owned by %s and user is not a member of the organization", userInfo.Login, owner)
}
}
// If we've reached this point, the token has access the repo and the user matches the owner or is a member of the owner org
return true, nil
}
func (g *GitHubDeviceAuth) ExtractGitHubRepoFromName(n string) (owner, repo string, err error) {
// match io.github.<owner>/<repo>
regexp := regexp.MustCompile(`io\.github\.([^/]+)/([^/]+)`)
matches := regexp.FindStringSubmatch(n)
if len(matches) != 3 {
return "", "", fmt.Errorf("invalid GitHub repository name: %s", n)
}
return matches[1], matches[2], nil
}
// extractGitHubRepo extracts the owner and repository name from a GitHub repository URL
func (g *GitHubDeviceAuth) ExtractGitHubRepo(repoURL string) (owner, repo string, err error) {
regexp := regexp.MustCompile(`github\.com/([^/]+)/([^/]+)`)
matches := regexp.FindStringSubmatch(repoURL)
if len(matches) != 3 {
return "", "", fmt.Errorf("invalid GitHub repository URL: %s", repoURL)
}
return matches[1], matches[2], nil
}
// checkOrgMembership checks if a user is a member of an organization
func (g *GitHubDeviceAuth) checkOrgMembership(token, username, org string) (bool, error) {
// Create request to check if user is a member of the organization
// GitHub API endpoint: GET /orgs/{org}/members/{username}
// true if status code is 204 No Content
// false if status code is 404 Not Found
url := fmt.Sprint("https://api.github.com/orgs/", org, "/members/", username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return false, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return true, nil
}
return false, fmt.Errorf("failed to check org membership: status %d", resp.StatusCode)
}