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
10 changed files with 802 additions and 193 deletions
200
internal/api/handlers/v0/auth/github_at.go
Normal file
200
internal/api/handlers/v0/auth/github_at.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
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/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
|
||||
}
|
||||
|
||||
// RegisterGitHubATEndpoint registers the GitHub access token authentication endpoint
|
||||
func RegisterGitHubATEndpoint(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-at",
|
||||
Summary: "Exchange GitHub OAuth access token for Registry JWT",
|
||||
Description: "Exchange a GitHub OAuth access token for a short-lived Registry JWT token",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
|
||||
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Token exchange failed", err)
|
||||
}
|
||||
|
||||
return &v0.Response[auth.TokenResponse]{
|
||||
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.AuthMethodGitHubAT,
|
||||
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)
|
||||
}
|
||||
627
internal/api/handlers/v0/auth/github_at_test.go
Normal file
627
internal/api/handlers/v0/auth/github_at_test.go
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
githubUserEndpoint = "/user"
|
||||
githubOrgsEndpoint = "/users/testuser/orgs"
|
||||
)
|
||||
|
||||
func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
||||
// Create test handler with mock config
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
t.Run("successful token exchange with user only", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
assert.Equal(t, "Bearer valid-github-token", authHeader)
|
||||
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.RegistryToken)
|
||||
assert.Greater(t, response.ExpiresAt, 0)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHubAT, claims.AuthMethod)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Len(t, claims.Permissions, 1)
|
||||
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
|
||||
assert.Equal(t, "io.github.testuser/*", claims.Permissions[0].ResourcePattern)
|
||||
})
|
||||
|
||||
t.Run("successful token exchange with organizations", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
{Login: "test-org-1", ID: 1},
|
||||
{Login: "test-org-2", ID: 2},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Len(t, claims.Permissions, 3) // User + 2 orgs
|
||||
|
||||
// Check permissions
|
||||
expectedPatterns := []string{
|
||||
"io.github.testuser/*",
|
||||
"io.github.test-org-1/*",
|
||||
"io.github.test-org-2/*",
|
||||
}
|
||||
for i, perm := range claims.Permissions {
|
||||
assert.Equal(t, auth.PermissionActionPublish, perm.Action)
|
||||
assert.Equal(t, expectedPatterns[i], perm.ResourcePattern)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid token returns error", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns 401
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"message": "Bad credentials"}`)) //nolint:errcheck
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "invalid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "GitHub API error")
|
||||
assert.Contains(t, err.Error(), "401")
|
||||
})
|
||||
|
||||
t.Run("GitHub API error on user fetch", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns 500
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == githubUserEndpoint {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to get GitHub user")
|
||||
})
|
||||
|
||||
t.Run("GitHub API error on orgs fetch", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to get GitHub organizations")
|
||||
})
|
||||
|
||||
t.Run("invalid GitHub username returns empty permissions", func(t *testing.T) {
|
||||
// Create mock GitHub API server with invalid username
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "user with spaces", // Invalid name
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case "/users/user with spaces/orgs":
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user with spaces", claims.AuthMethodSubject)
|
||||
assert.Empty(t, claims.Permissions) // No permissions due to invalid name
|
||||
})
|
||||
|
||||
t.Run("invalid org name is filtered out", func(t *testing.T) {
|
||||
// Create mock GitHub API server with invalid org name
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
{Login: "org with spaces", ID: 2}, // Invalid name
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Empty(t, claims.Permissions) // No permissions because one org has invalid name
|
||||
})
|
||||
|
||||
t.Run("malformed JSON response", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns invalid JSON
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == githubUserEndpoint {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{invalid json`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to decode")
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTTokenValidation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("generate and validate token", func(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.testuser/*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenResponse.RegistryToken)
|
||||
|
||||
// Validate token
|
||||
validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHubAT, validatedClaims.AuthMethod)
|
||||
assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject)
|
||||
assert.Len(t, validatedClaims.Permissions, 1)
|
||||
})
|
||||
|
||||
t.Run("token expiration", func(t *testing.T) {
|
||||
// Create claims with past expiration
|
||||
pastTime := time.Now().Add(-1 * time.Hour)
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(pastTime),
|
||||
IssuedAt: jwt.NewNumericDate(pastTime.Add(-1 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate token - should fail due to expiration
|
||||
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "token is expired")
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tamper with the token
|
||||
tamperedToken := tokenResponse.RegistryToken + "tampered"
|
||||
|
||||
// Validate token - should fail due to invalid signature
|
||||
_, err = jwtManager.ValidateToken(ctx, tamperedToken)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionResourceMatching(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
resource string
|
||||
pattern string
|
||||
action auth.PermissionAction
|
||||
expectedMatch bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
resource: "io.github.testuser/myrepo",
|
||||
pattern: "io.github.testuser/myrepo",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match",
|
||||
resource: "io.github.testuser/myrepo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "global wildcard",
|
||||
resource: "io.github.anyuser/anyrepo",
|
||||
pattern: "*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no match different user",
|
||||
resource: "io.github.otheruser/repo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: false,
|
||||
},
|
||||
{
|
||||
name: "no match different action",
|
||||
resource: "io.github.testuser/repo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionEdit,
|
||||
expectedMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
permissions := []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: tc.pattern,
|
||||
},
|
||||
}
|
||||
|
||||
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, permissions)
|
||||
assert.Equal(t, tc.expectedMatch, hasPermission)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidGitHubNames(t *testing.T) {
|
||||
// Create a minimal handler to test name validation
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
validNameTests := []struct {
|
||||
name string
|
||||
username string
|
||||
orgs []v0auth.GitHubUserOrOrg
|
||||
wantPerms int
|
||||
}{
|
||||
{
|
||||
name: "valid username only",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 1,
|
||||
},
|
||||
{
|
||||
name: "valid username with numbers",
|
||||
username: "user123",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 1,
|
||||
},
|
||||
{
|
||||
name: "valid username with org",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
},
|
||||
wantPerms: 2,
|
||||
},
|
||||
{
|
||||
name: "invalid username with spaces",
|
||||
username: "invalid user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 0, // Should return nil/empty permissions
|
||||
},
|
||||
{
|
||||
name: "invalid username with special chars",
|
||||
username: "user@invalid",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 0,
|
||||
},
|
||||
{
|
||||
name: "valid username with invalid org",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "invalid org", ID: 1},
|
||||
},
|
||||
wantPerms: 0, // Should return nil if any name is invalid
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range validNameTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/user":
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: tc.username,
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case "/users/" + tc.username + "/orgs":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tc.orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate the JWT token and check permissions
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, claims.Permissions, tc.wantPerms)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubHandler_Creation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
assert.NotNil(t, handler, "handler should not be nil")
|
||||
}
|
||||
|
||||
func TestConcurrentTokenExchange(t *testing.T) {
|
||||
// Test that the handler is thread-safe
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/user":
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Run multiple concurrent exchanges
|
||||
concurrency := 10
|
||||
errors := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
_, err := handler.ExchangeToken(ctx, fmt.Sprintf("token-%d", i))
|
||||
errors <- err
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect results
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errors
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
297
internal/api/handlers/v0/auth/github_oidc.go
Normal file
297
internal/api/handlers/v0/auth/github_oidc.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
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/model"
|
||||
)
|
||||
|
||||
// GitHubOIDCTokenExchangeInput represents the input for GitHub OIDC token exchange
|
||||
type GitHubOIDCTokenExchangeInput struct {
|
||||
Body struct {
|
||||
OIDCToken string `json:"oidc_token" doc:"GitHub Actions OIDC token" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubOIDCClaims represents the claims we need from a GitHub OIDC token
|
||||
type GitHubOIDCClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
RepositoryOwner string `json:"repository_owner"` // e.g., "octo-org"
|
||||
}
|
||||
|
||||
// JWKS represents a JSON Web Key Set
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// JWK represents a JSON Web Key
|
||||
type JWK struct {
|
||||
KTY string `json:"kty"`
|
||||
KID string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
}
|
||||
|
||||
// OIDCValidator defines the interface for OIDC token validation
|
||||
type OIDCValidator interface {
|
||||
ValidateToken(ctx context.Context, token string, audience string) (*GitHubOIDCClaims, error)
|
||||
}
|
||||
|
||||
// GitHubOIDCValidator validates GitHub OIDC tokens
|
||||
type GitHubOIDCValidator struct {
|
||||
jwksURL string
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewGitHubOIDCValidator creates a new GitHub OIDC validator
|
||||
func NewGitHubOIDCValidator() *GitHubOIDCValidator {
|
||||
return &GitHubOIDCValidator{
|
||||
jwksURL: "https://token.actions.githubusercontent.com/.well-known/jwks",
|
||||
issuer: "https://token.actions.githubusercontent.com",
|
||||
}
|
||||
}
|
||||
|
||||
// NewMockOIDCValidator creates a mock validator for testing
|
||||
func NewMockOIDCValidator(jwksURL, issuer string) *GitHubOIDCValidator {
|
||||
return &GitHubOIDCValidator{
|
||||
jwksURL: jwksURL,
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken validates a GitHub OIDC token
|
||||
func (v *GitHubOIDCValidator) ValidateToken(ctx context.Context, tokenString string, audience string) (*GitHubOIDCClaims, error) {
|
||||
// Parse token to get header for key ID
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&GitHubOIDCClaims{},
|
||||
func(token *jwt.Token) (any, error) {
|
||||
// Get key ID from header
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing kid in token header")
|
||||
}
|
||||
|
||||
// Find matching public key
|
||||
publicKey, err := v.getPublicKey(ctx, kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{"RS256"}),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Extract and validate claims
|
||||
claims, ok := token.Claims.(*GitHubOIDCClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// Validate issuer
|
||||
if claims.Issuer != v.issuer {
|
||||
return nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.issuer, claims.Issuer)
|
||||
}
|
||||
|
||||
// Validate audience
|
||||
foundAudience := false
|
||||
for _, aud := range claims.Audience {
|
||||
if aud == audience {
|
||||
foundAudience = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAudience {
|
||||
return nil, fmt.Errorf("invalid audience: expected %s, got %v", audience, claims.Audience)
|
||||
}
|
||||
|
||||
// Validate repository format
|
||||
if claims.RepositoryOwner == "" {
|
||||
return nil, fmt.Errorf("repository owner claim is required")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// fetchJWKS fetches the JSON Web Key Set from GitHub
|
||||
func (v *GitHubOIDCValidator) fetchJWKS(ctx context.Context) (*JWKS, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("JWKS endpoint returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var jwks JWKS
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWKS: %w", err)
|
||||
}
|
||||
|
||||
return &jwks, nil
|
||||
}
|
||||
|
||||
// getPublicKey extracts the RSA public key for the given key ID
|
||||
func (v *GitHubOIDCValidator) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
// Fetch JWKS from GitHub
|
||||
jwks, err := v.fetchJWKS(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.KID == kid {
|
||||
return v.parseRSAPublicKey(key)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("key with ID %s not found", kid)
|
||||
}
|
||||
|
||||
// parseRSAPublicKey converts JWK to RSA public key
|
||||
func (v *GitHubOIDCValidator) parseRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) {
|
||||
if jwk.KTY != "RSA" {
|
||||
return nil, fmt.Errorf("invalid key type: expected RSA, got %s", jwk.KTY)
|
||||
}
|
||||
|
||||
// Decode modulus (n)
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode modulus: %w", err)
|
||||
}
|
||||
|
||||
// Decode exponent (e)
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode exponent: %w", err)
|
||||
}
|
||||
|
||||
// Convert to big integers
|
||||
n := new(big.Int).SetBytes(nBytes)
|
||||
e := new(big.Int).SetBytes(eBytes)
|
||||
|
||||
return &rsa.PublicKey{
|
||||
N: n,
|
||||
E: int(e.Int64()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GitHubOIDCHandler handles GitHub OIDC authentication
|
||||
type GitHubOIDCHandler struct {
|
||||
config *config.Config
|
||||
jwtManager *auth.JWTManager
|
||||
validator OIDCValidator
|
||||
}
|
||||
|
||||
// NewGitHubOIDCHandler creates a new GitHub OIDC handler
|
||||
func NewGitHubOIDCHandler(cfg *config.Config) *GitHubOIDCHandler {
|
||||
return &GitHubOIDCHandler{
|
||||
config: cfg,
|
||||
jwtManager: auth.NewJWTManager(cfg),
|
||||
validator: NewGitHubOIDCValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetValidator sets a custom OIDC validator (used for testing)
|
||||
func (h *GitHubOIDCHandler) SetValidator(validator OIDCValidator) {
|
||||
h.validator = validator
|
||||
}
|
||||
|
||||
// RegisterGitHubOIDCEndpoint registers the GitHub OIDC authentication endpoint
|
||||
func RegisterGitHubOIDCEndpoint(api huma.API, cfg *config.Config) {
|
||||
handler := NewGitHubOIDCHandler(cfg)
|
||||
|
||||
// GitHub OIDC token exchange endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "exchange-github-oidc-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/github-oidc",
|
||||
Summary: "Exchange GitHub OIDC token for Registry JWT",
|
||||
Description: "Exchange a GitHub Actions OIDC token for a short-lived Registry JWT token",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *GitHubOIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
|
||||
response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Token exchange failed", err)
|
||||
}
|
||||
|
||||
return &v0.Response[auth.TokenResponse]{
|
||||
Body: *response,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges a GitHub OIDC token for a Registry JWT token
|
||||
func (h *GitHubOIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) {
|
||||
// Validate OIDC token with audience "mcp-registry"
|
||||
claims, err := h.validator.ValidateToken(ctx, oidcToken, "mcp-registry")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate OIDC token: %w", err)
|
||||
}
|
||||
|
||||
// Extract repository information and build permissions
|
||||
permissions := h.buildPermissions(claims)
|
||||
|
||||
// Create JWT claims with GitHub OIDC info
|
||||
jwtClaims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHubOIDC,
|
||||
AuthMethodSubject: claims.Subject, // e.g. "repo:octo-org/octo-repo:environment:prod"
|
||||
Permissions: permissions,
|
||||
}
|
||||
|
||||
// Generate Registry JWT token
|
||||
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
func (h *GitHubOIDCHandler) buildPermissions(claims *GitHubOIDCClaims) []auth.Permission {
|
||||
permissions := []auth.Permission{}
|
||||
|
||||
// Validate repository owner name
|
||||
if !isValidGitHubName(claims.RepositoryOwner) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Grant publish permissions for the repository owner's namespace
|
||||
// We grant io.github.<owner>/* rather than io.github./repo/* because many people have monorepo setups where they want to deploy multiple servers from
|
||||
// This also reflects GitHub's permission model, in that GitHub Actions can push to any GitHub package in the repository owner's namespace (e.g. for GHCR)
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", claims.RepositoryOwner),
|
||||
})
|
||||
|
||||
return permissions
|
||||
}
|
||||
217
internal/api/handlers/v0/auth/github_oidc_test.go
Normal file
217
internal/api/handlers/v0/auth/github_oidc_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
||||
internalauth "github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Mock OIDC validator for testing
|
||||
type MockOIDCValidator struct {
|
||||
validateFunc func(ctx context.Context, token string, audience string) (*auth.GitHubOIDCClaims, error)
|
||||
}
|
||||
|
||||
func (m *MockOIDCValidator) ValidateToken(ctx context.Context, token string, audience string) (*auth.GitHubOIDCClaims, error) {
|
||||
return m.validateFunc(ctx, token, audience)
|
||||
}
|
||||
|
||||
func TestGitHubOIDCHandler_ExchangeToken(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // 32 bytes hex
|
||||
}
|
||||
|
||||
handler := auth.NewGitHubOIDCHandler(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mockValidator *MockOIDCValidator
|
||||
expectError bool
|
||||
expectedSubject string
|
||||
expectedPerms int
|
||||
}{
|
||||
{
|
||||
name: "successful token exchange",
|
||||
mockValidator: &MockOIDCValidator{
|
||||
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
|
||||
return &auth.GitHubOIDCClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "repo:octo-org/octo-repo:environment:prod",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
Audience: jwt.ClaimStrings{"mcp-registry"},
|
||||
},
|
||||
RepositoryOwner: "octo-org",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedSubject: "repo:octo-org/octo-repo:environment:prod",
|
||||
expectedPerms: 1,
|
||||
},
|
||||
{
|
||||
name: "validation failure",
|
||||
mockValidator: &MockOIDCValidator{
|
||||
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
|
||||
return nil, fmt.Errorf("token validation failed")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid repository owner name",
|
||||
mockValidator: &MockOIDCValidator{
|
||||
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
|
||||
return &auth.GitHubOIDCClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "repo:invalid@name/octo-repo:environment:prod",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
Audience: jwt.ClaimStrings{"mcp-registry"},
|
||||
},
|
||||
RepositoryOwner: "invalid@name", // invalid character
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
expectError: false, // Handler should succeed but return empty permissions
|
||||
expectedSubject: "repo:invalid@name/octo-repo:environment:prod",
|
||||
expectedPerms: 0, // No permissions due to invalid name
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler.SetValidator(tt.mockValidator)
|
||||
|
||||
response, err := handler.ExchangeToken(context.Background(), "test-oidc-token")
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.RegistryToken)
|
||||
assert.Greater(t, response.ExpiresAt, 0)
|
||||
|
||||
// Validate the generated JWT token
|
||||
jwtManager := internalauth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, model.AuthMethodGitHubOIDC, claims.AuthMethod)
|
||||
assert.Equal(t, tt.expectedSubject, claims.AuthMethodSubject)
|
||||
assert.Len(t, claims.Permissions, tt.expectedPerms)
|
||||
|
||||
if tt.expectedPerms > 0 {
|
||||
assert.Equal(t, internalauth.PermissionActionPublish, claims.Permissions[0].Action)
|
||||
assert.True(t, strings.HasPrefix(claims.Permissions[0].ResourcePattern, "io.github."))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionsFromOIDC(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}
|
||||
handler := auth.NewGitHubOIDCHandler(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
claims *auth.GitHubOIDCClaims
|
||||
expectedPerms []internalauth.Permission
|
||||
}{
|
||||
{
|
||||
name: "valid repository owner",
|
||||
claims: &auth.GitHubOIDCClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "repo:octo-org/octo-repo:environment:prod",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
Audience: jwt.ClaimStrings{"mcp-registry"},
|
||||
},
|
||||
RepositoryOwner: "octo-org",
|
||||
},
|
||||
expectedPerms: []internalauth.Permission{
|
||||
{
|
||||
Action: internalauth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.octo-org/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid repository owner name",
|
||||
claims: &auth.GitHubOIDCClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "repo:invalid@name/octo-repo:environment:prod",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
Audience: jwt.ClaimStrings{"mcp-registry"},
|
||||
},
|
||||
RepositoryOwner: "invalid@name", // contains invalid character
|
||||
},
|
||||
expectedPerms: nil, // No permissions for invalid names
|
||||
},
|
||||
{
|
||||
name: "user repository",
|
||||
claims: &auth.GitHubOIDCClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "repo:username/octo-repo:environment:prod",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
Audience: jwt.ClaimStrings{"mcp-registry"},
|
||||
},
|
||||
RepositoryOwner: "username",
|
||||
},
|
||||
expectedPerms: []internalauth.Permission{
|
||||
{
|
||||
Action: internalauth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.username/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Access the method through reflection since it's not exported
|
||||
// For testing purposes, we'll create a simple test by calling ExchangeToken
|
||||
// and validating the resulting permissions
|
||||
mockValidator := &MockOIDCValidator{
|
||||
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
|
||||
return tt.claims, nil
|
||||
},
|
||||
}
|
||||
handler.SetValidator(mockValidator)
|
||||
|
||||
response, err := handler.ExchangeToken(context.Background(), "test-token")
|
||||
|
||||
if tt.expectedPerms == nil {
|
||||
// For invalid names, we expect empty permissions but successful token generation
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT to check permissions
|
||||
jwtManager := internalauth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, claims.Permissions, 0)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT to check permissions
|
||||
jwtManager := internalauth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedPerms, claims.Permissions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,11 @@ import (
|
|||
|
||||
// RegisterAuthEndpoints registers all authentication endpoints
|
||||
func RegisterAuthEndpoints(api huma.API, cfg *config.Config) {
|
||||
// Register GitHub authentication endpoint
|
||||
RegisterGitHubEndpoint(api, cfg)
|
||||
// Register GitHub access token authentication endpoint
|
||||
RegisterGitHubATEndpoint(api, cfg)
|
||||
|
||||
// Register GitHub OIDC authentication endpoint
|
||||
RegisterGitHubOIDCEndpoint(api, cfg)
|
||||
|
||||
// Register anonymous authentication endpoint
|
||||
RegisterNoneEndpoint(api, cfg)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
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/model"
|
||||
|
|
@ -41,17 +42,13 @@ func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
|
|||
Summary: "Get anonymous Registry JWT",
|
||||
Description: "Get a short-lived Registry JWT token for publishing to io.modelcontextprotocol.anonymous/* namespace",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}, error) {
|
||||
}, func(ctx context.Context, _ *struct{}) (*v0.Response[auth.TokenResponse], error) {
|
||||
response, err := handler.GetAnonymousToken(ctx)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("Failed to generate token", err)
|
||||
}
|
||||
|
||||
return &struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}{
|
||||
return &v0.Response[auth.TokenResponse]{
|
||||
Body: *response,
|
||||
}, nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ func TestPublishIntegration(t *testing.T) {
|
|||
|
||||
// Generate valid JWT token
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
|
|
@ -210,7 +210,7 @@ func TestPublishIntegration(t *testing.T) {
|
|||
|
||||
// Generate valid JWT token but with different permissions
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.myuser/*"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
},
|
||||
},
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "example",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||
|
|
@ -187,7 +187,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
},
|
||||
},
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
|||
|
||||
t.Run("generate and verify valid token", func(t *testing.T) {
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
|
|
@ -50,7 +50,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
|||
// Verify token
|
||||
verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHub, verifiedClaims.AuthMethod)
|
||||
assert.Equal(t, model.AuthMethodGitHubAT, verifiedClaims.AuthMethod)
|
||||
assert.Equal(t, "testuser", verifiedClaims.AuthMethodSubject)
|
||||
assert.Equal(t, "mcp-registry", verifiedClaims.Issuer)
|
||||
assert.Len(t, verifiedClaims.Permissions, 1)
|
||||
|
|
@ -100,7 +100,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
|||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // Already expired
|
||||
},
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
|||
differentJWTManager := auth.NewJWTManager(differentCfg)
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
|||
|
||||
t.Run("multiple permissions", func(t *testing.T) {
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethod: model.AuthMethodGitHubAT,
|
||||
AuthMethodSubject: "admin",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ package model
|
|||
type AuthMethod string
|
||||
|
||||
const (
|
||||
// GitHub OAuth authentication
|
||||
AuthMethodGitHub AuthMethod = "github"
|
||||
// GitHub OAuth authentication (access token)
|
||||
AuthMethodGitHubAT AuthMethod = "github-at"
|
||||
// GitHub Actions OIDC authentication
|
||||
AuthMethodGitHubOIDC AuthMethod = "github-oidc"
|
||||
// No authentication - should only be used for local development and testing
|
||||
AuthMethodNone AuthMethod = "none"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue