auth: Add GitHub OIDC auth (#278)
Adds support for auth'ing to the registry via [GitHub Actions OIDC](https://docs.github.com/en/actions/concepts/security/openid-connect). Fixes #271 ## Motivation and Context People will probably want to publish to the registry from CI environments. DNS auth can work here fine (https://github.com/modelcontextprotocol/registry/issues/270). But for people who haven't set up DNS, the standard GitHub device flow won't really work here. We could make this nice by supporting GitHub Actions OIDC. ## How Has This Been Tested? Tested locally with a real GitHub OIDC token from a personal repo. ## Breaking Changes None ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
This commit is contained in:
parent
e9fe6f9ab8
commit
12ab632d32
1 changed files with 802 additions and 193 deletions
|
|
@ -1,203 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// GitHubTokenExchangeInput represents the input for GitHub token exchange
|
||||
type GitHubTokenExchangeInput struct {
|
||||
Body struct {
|
||||
GitHubToken string `json:"github_token" doc:"GitHub OAuth token" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubHandler handles GitHub authentication
|
||||
type GitHubHandler struct {
|
||||
config *config.Config
|
||||
jwtManager *auth.JWTManager
|
||||
baseURL string // Configurable for testing
|
||||
}
|
||||
|
||||
// NewGitHubHandler creates a new GitHub handler
|
||||
func NewGitHubHandler(cfg *config.Config) *GitHubHandler {
|
||||
return &GitHubHandler{
|
||||
config: cfg,
|
||||
jwtManager: auth.NewJWTManager(cfg),
|
||||
baseURL: "https://api.github.com",
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL sets the base URL for GitHub API (used for testing)
|
||||
func (h *GitHubHandler) SetBaseURL(url string) {
|
||||
h.baseURL = url
|
||||
}
|
||||
|
||||
// RegisterGitHubEndpoint registers the GitHub authentication endpoint
|
||||
func RegisterGitHubEndpoint(api huma.API, cfg *config.Config) {
|
||||
handler := NewGitHubHandler(cfg)
|
||||
|
||||
// GitHub token exchange endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "exchange-github-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/github",
|
||||
Summary: "Exchange GitHub token for Registry JWT",
|
||||
Description: "Exchange a GitHub OAuth token for a short-lived Registry JWT token",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}, error) {
|
||||
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Token exchange failed", err)
|
||||
}
|
||||
|
||||
return &struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}{
|
||||
Body: *response,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges a GitHub OAuth token for a Registry JWT token
|
||||
func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (*auth.TokenResponse, error) {
|
||||
// Get GitHub user information
|
||||
user, err := h.getGitHubUser(ctx, githubToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub user: %w", err)
|
||||
}
|
||||
|
||||
// Get user's organizations
|
||||
orgs, err := h.getGitHubUserOrgs(ctx, user.Login, githubToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub organizations: %w", err)
|
||||
}
|
||||
|
||||
// Build permissions based on user and organizations
|
||||
permissions := h.buildPermissions(user.Login, orgs)
|
||||
|
||||
// Create JWT claims with GitHub user info
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: user.Login,
|
||||
Permissions: permissions,
|
||||
}
|
||||
|
||||
// Generate Registry JWT token
|
||||
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
type GitHubUserOrOrg struct {
|
||||
Login string `json:"login"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// getGitHubUser gets the authenticated user's information
|
||||
func (h *GitHubHandler) getGitHubUser(ctx context.Context, token string) (*GitHubUserOrOrg, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/user", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var user GitHubUserOrOrg
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode user response: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, username string, token string) ([]GitHubUserOrOrg, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/users/"+username+"/orgs", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user organizations: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var orgs []GitHubUserOrOrg
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode organizations response: %w", err)
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// buildPermissions builds permissions based on GitHub user and their organizations
|
||||
func (h *GitHubHandler) buildPermissions(username string, orgs []GitHubUserOrOrg) []auth.Permission {
|
||||
permissions := []auth.Permission{}
|
||||
|
||||
// Assert user and org names match expected regex, to harden against people doing weird things in names
|
||||
if !isValidGitHubName(username) {
|
||||
return nil
|
||||
}
|
||||
for _, org := range orgs {
|
||||
if !isValidGitHubName(org.Login) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add permission for user's own namespace
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
|
||||
})
|
||||
|
||||
// Add permissions for each organization
|
||||
for _, org := range orgs {
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
|
||||
})
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
func isValidGitHubName(name string) bool {
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(name)
|
||||
}
|
||||
Loading…
Reference in a new issue