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:
Avinash Sridhar 2025-04-28 09:40:42 -04:00 • committed by Toby Padilla
commit 2089c5aaa1
12 changed files with 924 additions and 35 deletions

View file

@ -0,0 +1,117 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"io"
"net/http"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/model"
)
// StartAuthHandler handles requests to start an authentication flow
func StartAuthHandler(authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow POST method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Parse request body into AuthRequest struct
var authReq struct {
Method string `json:"method"`
RepoRef string `json:"repo_ref"`
}
err = json.Unmarshal(body, &authReq)
if err != nil {
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
return
}
// Validate required fields
if authReq.Method == "" {
http.Error(w, "Auth method is required", http.StatusBadRequest)
return
}
// Convert string method to enum type
var method model.AuthMethod
switch authReq.Method {
case "github":
method = model.AuthMethodGitHub
default:
http.Error(w, "Unsupported authentication method", http.StatusBadRequest)
return
}
// Start auth flow
flowInfo, statusToken, err := authService.StartAuthFlow(r.Context(), method, authReq.RepoRef)
if err != nil {
http.Error(w, "Failed to start auth flow: "+err.Error(), http.StatusInternalServerError)
return
}
// Return successful response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"flow_info": flowInfo,
"status_token": statusToken,
"expires_in": 300, // 5 minutes
})
}
}
// CheckAuthStatusHandler handles requests to check the status of an authentication flow
func CheckAuthStatusHandler(authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow GET method
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get status token from query parameter
statusToken := r.URL.Query().Get("token")
if statusToken == "" {
http.Error(w, "Status token is required", http.StatusBadRequest)
return
}
// Check auth status
token, err := authService.CheckAuthStatus(r.Context(), statusToken)
if err != nil {
if err.Error() == "pending" {
// Auth is still pending
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "pending",
})
return
}
// Other error
http.Error(w, "Failed to check auth status: "+err.Error(), http.StatusInternalServerError)
return
}
// Authentication completed successfully
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "complete",
"token": token,
})
}
}

View file

@ -6,12 +6,24 @@ import (
"io"
"net/http"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/service"
)
// httpError represents an HTTP error with a message and status code
type httpError struct {
msg string
status int
}
// Error returns the error message
func (e *httpError) Error() string {
return e.msg
}
// PublishHandler handles requests to publish new server details to the registry
func PublishHandler(registry service.RegistryService) http.HandlerFunc {
func PublishHandler(registry service.RegistryService, authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow POST method
if r.Method != http.MethodPost {
@ -27,26 +39,48 @@ func PublishHandler(registry service.RegistryService) http.HandlerFunc {
}
defer r.Body.Close()
// Parse request body into ServerDetail struct
var serverDetail model.ServerDetail
err = json.Unmarshal(body, &serverDetail)
// Parse request body into PublishRequest struct
var publishReq model.PublishRequest
err = json.Unmarshal(body, &publishReq)
if err != nil {
http.Error(w, "Invalid request payload", http.StatusBadRequest)
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
return
}
// Get server details from the request
serverDetail := publishReq.ServerDetail
// Validate required fields
if serverDetail.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// version is required
// Version is required
if serverDetail.VersionDetail.Version == "" {
http.Error(w, "Version is required", http.StatusBadRequest)
return
}
// Validate authentication credentials
if authService != nil {
publishReq.Authentication.RepoRef = serverDetail.Name
valid, err := authService.ValidateAuth(r.Context(), publishReq.Authentication)
if err != nil {
if err == auth.ErrAuthRequired {
http.Error(w, "Authentication is required for publishing", http.StatusUnauthorized)
return
}
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
return
}
if !valid {
http.Error(w, "Invalid authentication credentials", http.StatusUnauthorized)
return
}
}
// Call the publish method on the registry service
err = registry.Publish(&serverDetail)
if err != nil {

View file

@ -4,16 +4,17 @@ package router
import (
"net/http"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/service"
)
// New creates a new router with all API versions registered
func New(cfg *config.Config, registry service.RegistryService) *http.ServeMux {
func New(cfg *config.Config, registry service.RegistryService, authService auth.Service) *http.ServeMux {
mux := http.NewServeMux()
// Register routes for all API versions
RegisterV0Routes(mux, cfg, registry)
RegisterV0Routes(mux, cfg, registry, authService)
return mux
}

View file

@ -5,18 +5,19 @@ import (
"net/http"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/service"
)
// RegisterV0Routes registers all v0 API routes to the provided router
func RegisterV0Routes(mux *http.ServeMux, cfg *config.Config, registry service.RegistryService) {
func RegisterV0Routes(mux *http.ServeMux, cfg *config.Config, registry service.RegistryService, authService auth.Service) {
// Register v0 endpoints
mux.HandleFunc("/v0/health", v0.HealthHandler())
mux.HandleFunc("/v0/servers", v0.ServersHandler(registry))
mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry))
mux.HandleFunc("/v0/ping", v0.PingHandler(cfg))
mux.HandleFunc("/v0/publish", v0.PublishHandler(registry))
mux.HandleFunc("/v0/publish", v0.PublishHandler(registry, authService))
// Register Swagger UI routes
mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler())

View file

@ -6,27 +6,30 @@ import (
"net/http"
"github.com/modelcontextprotocol/registry/internal/api/router"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/service"
)
// Server represents the HTTP server
type Server struct {
config *config.Config
registry service.RegistryService
router *http.ServeMux
server *http.Server
config *config.Config
registry service.RegistryService
authService auth.Service
router *http.ServeMux
server *http.Server
}
// NewServer creates a new HTTP server
func NewServer(cfg *config.Config, registryService service.RegistryService) *Server {
func NewServer(cfg *config.Config, registryService service.RegistryService, authService auth.Service) *Server {
// Create router with all API versions registered
mux := router.New(cfg, registryService)
mux := router.New(cfg, registryService, authService)
server := &Server{
config: cfg,
registry: registryService,
router: mux,
config: cfg,
registry: registryService,
authService: authService,
router: mux,
server: &http.Server{
Addr: cfg.ServerAddress,
Handler: mux,

28
internal/auth/auth.go Normal file
View 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
View 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
View 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
}
}

View file

@ -13,6 +13,11 @@ type Config struct {
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
Environment string `env:"ENVIRONMENT" envDefault:"production"`
Version string `env:"VERSION" envDefault:"dev"`
// GitHub OAuth settings
GithubClientID string `env:"GITHUB_CLIENT_ID" envDefault:""`
GithubClientSecret string `env:"GITHUB_CLIENT_SECRET" envDefault:""`
RequireAuth bool `env:"REQUIRE_AUTH" envDefault:"false"` // Whether to require authentication for publishing
}
// NewConfig creates a new configuration with default values

View file

@ -192,15 +192,6 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
return fmt.Errorf("version must be greater than existing version")
}
// update the existing entry to not be the latest version
if existingEntry.ID != "" {
_, err = db.collection.UpdateOne(ctx, bson.M{"id": existingEntry.ID}, bson.M{"$set": bson.M{"versiondetail.islatest": false}})
if err != nil {
return fmt.Errorf("error updating existing entry: %w", err)
}
}
serverDetail.ID = uuid.New().String()
serverDetail.VersionDetail.IsLatest = true
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
@ -214,6 +205,15 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
return fmt.Errorf("error inserting entry: %w", err)
}
// update the existing entry to not be the latest version
if existingEntry.ID != "" {
_, err = db.collection.UpdateOne(ctx, bson.M{"id": existingEntry.ID}, bson.M{"$set": bson.M{"versiondetail.islatest": false}})
if err != nil {
return fmt.Errorf("error updating existing entry: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,65 @@
/v0/publish:
post:
tags:
- servers
summary: Publish a server to the registry
description: Adds a new server to the MCP registry with authentication
operationId: publishServer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
server_detail:
$ref: '#/components/schemas/ServerDetail'
authentication:
$ref: '#/components/schemas/Authentication'
auth_status_token:
type: string
description: Optional status token from a device authorization flow
responses:
'201':
description: Server published successfully
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Server publication successful
id:
type: string
example: 1234567890abcdef12345678
'400':
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Name is required
'401':
description: Authentication failed
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Authentication is required for publishing
'500':
description: Server error
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Failed to publish server details

View file

@ -1,5 +1,29 @@
package model
// AuthMethod represents the authentication method used
type AuthMethod string
const (
// AuthMethodGitHub represents GitHub OAuth authentication
AuthMethodGitHub AuthMethod = "github"
// AuthMethodNone represents no authentication
AuthMethodNone AuthMethod = "none"
)
// Authentication holds information about the authentication method and credentials
type Authentication struct {
Method AuthMethod `json:"method,omitempty"`
Token string `json:"token,omitempty"`
RepoRef string `json:"repo_ref,omitempty"`
}
// PublishRequest represents a request to publish a server to the registry
type PublishRequest struct {
ServerDetail ServerDetail `json:"server_detail"`
Authentication Authentication `json:"authentication,omitempty"`
AuthStatusToken string `json:"-"` // Used internally for device flows
}
type Entry struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@ -25,8 +49,8 @@ type ServerDetail struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
VersionDetail VersionDetail `json:"version_detail,omitempty"`
Repository Repository `json:"repository,omitempty"`
RegistryCanonical string `json:"registry_canonical,omitempty"`
Repository Repository `json:"-"` // Not included in the JSON response
RegistryCanonical string `json:"-"` // Not included in the JSON response
Registries []Registries `json:"registries,omitempty"`
Remotes []Remotes `json:"remotes,omitempty"`
}