feat(auth): Implement GitHub OAuth authentication flow for package publishing
- Added authentication service and GitHub OAuth integration. - Introduced new authentication methods and structures in the model. - Updated the API to handle authentication during the publishing process. - Created handlers for starting and checking the status of the OAuth flow. - Enhanced the publish handler to validate authentication credentials. - Updated configuration to include GitHub OAuth settings. - Added tests for the OAuth flow and publishing with authentication. - Updated documentation to reflect changes in the API and authentication process.
This commit is contained in:
parent
582d93a103
commit
2089c5aaa1
3 changed files with 924 additions and 35 deletions
28
internal/auth/auth.go
Normal file
28
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Package auth provides authentication mechanisms for the MCP registry
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthRequired is returned when authentication is required but not provided
|
||||
ErrAuthRequired = errors.New("authentication required")
|
||||
// ErrUnsupportedAuthMethod is returned when an unsupported auth method is used
|
||||
ErrUnsupportedAuthMethod = errors.New("unsupported authentication method")
|
||||
)
|
||||
|
||||
// Service defines the authentication service interface
|
||||
type Service interface {
|
||||
// StartAuthFlow initiates an authentication flow and returns the flow information
|
||||
StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error)
|
||||
|
||||
// CheckAuthStatus checks the status of an authentication flow using a status token
|
||||
CheckAuthStatus(ctx context.Context, statusToken string) (string, error)
|
||||
|
||||
// ValidateAuth validates the authentication credentials
|
||||
ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error)
|
||||
}
|
||||
158
internal/auth/github.go
Normal file
158
internal/auth/github.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// Package auth provides authentication mechanisms for the MCP registry
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
GitHubValidateTokenURL = "https://api.github.com/applications/CLIENT_ID/token"
|
||||
)
|
||||
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
// First, check if the token is valid using the app token check endpoint
|
||||
req, err := http.NewRequest("POST", GitHubValidateTokenURL, bytes.NewBuffer([]byte(`{"access_token": "`+token+`"}`)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req.URL.Path = fmt.Sprintf("/applications/%s/token", g.config.ClientID)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.SetBasicAuth(g.config.ClientID, g.config.ClientSecret)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("token validation failed: %s", body)
|
||||
}
|
||||
|
||||
var validationResp TokenValidationResponse
|
||||
err = json.Unmarshal(body, &validationResp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Now check if the token has permissions to the specific repository
|
||||
// Use the GitHub API to check repository-specific permissions
|
||||
repoCheckURL := fmt.Sprintf("https://api.github.com/repos/%s", requiredRepo)
|
||||
repoReq, err := http.NewRequest("GET", repoCheckURL, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
repoReq.Header.Set("Accept", "application/vnd.github+json")
|
||||
repoReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
repoResp, err := client.Do(repoReq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer repoResp.Body.Close()
|
||||
|
||||
// Read and check response
|
||||
if repoResp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("token does not have access to repository: %s (status: %d)", requiredRepo, repoResp.StatusCode)
|
||||
}
|
||||
|
||||
// If we've reached this point, the token has access the repo
|
||||
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
|
||||
}
|
||||
67
internal/auth/service.go
Normal file
67
internal/auth/service.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// AuthServiceImpl implements the Service interface
|
||||
type AuthServiceImpl struct {
|
||||
config *config.Config
|
||||
githubAuth *GitHubDeviceAuth
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.Config) Service {
|
||||
githubConfig := GitHubOAuthConfig{
|
||||
ClientID: cfg.GithubClientID,
|
||||
ClientSecret: cfg.GithubClientSecret,
|
||||
}
|
||||
|
||||
return &AuthServiceImpl{
|
||||
config: cfg,
|
||||
githubAuth: NewGitHubDeviceAuth(githubConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) {
|
||||
// return not implemented error
|
||||
return nil, "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) {
|
||||
// return not implemented error
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// ValidateAuth validates authentication credentials
|
||||
func (s *AuthServiceImpl) ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error) {
|
||||
// If authentication is not required, allow access
|
||||
if !s.config.RequireAuth {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If authentication is required but not provided
|
||||
if auth.Method == "" || auth.Method == model.AuthMethodNone {
|
||||
return false, ErrAuthRequired
|
||||
}
|
||||
|
||||
switch auth.Method {
|
||||
case model.AuthMethodGitHub:
|
||||
// Extract repo reference from the repository URL if it's not provided
|
||||
owner, repo, err := s.githubAuth.ExtractGitHubRepoFromName(auth.RepoRef)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
repoRef := fmt.Sprintf("%s/%s", owner, repo)
|
||||
if repoRef == "" {
|
||||
return false, fmt.Errorf("repository reference is required for GitHub authentication")
|
||||
}
|
||||
return s.githubAuth.ValidateToken(auth.Token, repoRef)
|
||||
default:
|
||||
return false, ErrUnsupportedAuthMethod
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue