Publish Auth Interface (#112)

This pull request introduces significant updates to the authentication
system for the MCP Registry Publisher Tool. The changes focus on
transitioning to an interface-based authentication system, implementing
GitHub OAuth device flow as the default authentication method, and
restructuring the codebase for better modularity and extensibility.

Adding an interface to the publishing CLI, so that it'll be easier to
add more auth methods as they become available.
This commit is contained in:
Avinash Sridhar 2025-05-30 15:05:15 -04:00 • committed by GitHub
commit a4cefcf05f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 465 additions and 266 deletions

View file

@ -0,0 +1,104 @@
# Authentication System
The publisher tool now uses an interface-based authentication system that allows for multiple authentication mechanisms.
## Architecture
### Provider Interface
The `Provider` interface is defined in `auth/interface.go` and provides the following methods:
- `GetToken(ctx context.Context) (string, error)` - Retrieves or generates an authentication token
- `NeedsLogin() bool` - Checks if a new login flow is required
- `Login(ctx context.Context) error` - Performs the authentication flow
- `Name() string` - Returns the name of the authentication provider
### Available Authentication Providers
#### 1. GitHub OAuth Provider
- **Location**: `auth/github/oauth.go`
- **Usage**: Uses GitHub's device flow for authentication
- **Example**: `github.NewOAuthProvider(forceLogin, registryURL)`
## How to Add New Authentication Providers
1. Create a new package under `auth/` directory (e.g., `auth/custom/`)
2. Implement the `Provider` interface
3. Add any necessary configuration or initialization functions
4. Update the main application to use the new provider
### Example Implementation
```go
package custom
import (
"context"
"fmt"
)
type CustomProvider struct {
// your custom fields
}
func NewCustomProvider(config string) *CustomProvider {
return &CustomProvider{
// initialize your provider
}
}
func (cp *CustomProvider) GetToken(ctx context.Context) (string, error) {
// implement token retrieval logic
return "custom-token", nil
}
func (cp *CustomProvider) NeedsLogin() bool {
// implement login check logic
return false
}
func (cp *CustomProvider) Login(ctx context.Context) error {
// implement authentication flow
return nil
}
func (cp *CustomProvider) Name() string {
return "custom-auth"
}
```
## Usage in Main Application
The main application automatically selects the appropriate authentication provider:
1. Uses `GitHub OAuth Provider` by default
2. Future providers can be added by extending the provider selection logic
```go
// Create the appropriate auth provider based on configuration
var authProvider auth.Provider
switch authMethod {
case "github-oauth":
log.Println("Using GitHub OAuth for authentication")
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
default:
log.Printf("Unsupported authentication method: %s\n", authMethod)
return
}
// Check if login is needed and perform authentication
ctx := context.Background()
if authProvider.NeedsLogin() {
err := authProvider.Login(ctx)
if err != nil {
log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error())
return
}
}
// Get the token
token, err := authProvider.GetToken(ctx)
```
This design allows for easy extension and testing of different authentication mechanisms while maintaining a clean separation of concerns.

View file

@ -0,0 +1,294 @@
package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
const (
tokenFilePath = ".mcpregistry_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"`
}
// 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 GitHub access token
func (g *OAuthProvider) GetToken(_ context.Context) (string, error) {
return readToken()
}
// NeedsLogin checks if a new login is required
func (g *OAuthProvider) NeedsLogin() bool {
if g.forceLogin {
return true
}
_, statErr := os.Stat(tokenFilePath)
return os.IsNotExist(statErr)
}
// 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-oauth"
}
// 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(tokenFilePath, []byte(token), 0600)
}
// readToken reads the GitHub access token from a local file
func readToken() (string, error) {
tokenData, err := os.ReadFile(tokenFilePath)
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
}

View file

@ -0,0 +1,21 @@
package auth
import "context"
// Provider defines the interface for authentication mechanisms
type Provider interface {
// GetToken retrieves or generates an authentication token
// It returns the token string and any error encountered
GetToken(ctx context.Context) (string, error)
// NeedsLogin checks if a new login is required
// This can check for existing tokens, expiry, etc.
NeedsLogin() bool
// Login performs the authentication flow
// This might involve user interaction, device flows, etc.
Login(ctx context.Context) error
// Name returns the name of the authentication provider
Name() string
}