auth: New auth flow (#273)

Fixes #264. Adds endpoints:

```
/v0/auth/github    # exchange a github token for a registry token that gives you access to namespaces io.github.username and io.github.org for your orgs
/v0/auth/none     # get a registry token for namespace io.modelcontextprotocol.anonymous (only for testing/local dev purposes)
```

Updates `/v0/publish` to accept one of these registry tokens.

## Motivation and Context

See #264

## How Has This Been Tested?

- Running locally and getting a token from `/v0/auth/none` and then
publishing to `/v0/publish`
- Updating the integration tests that get a token and then publish, and
them passing

## Breaking Changes

This is a breaking change to the auth API. This is fine for registry
given it's not deployed yet.

## 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)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] 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

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
adam jones 2025-08-15 13:03:35 +01:00 • committed by GitHub
commit c733cedaf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1759 additions and 1537 deletions

View file

@ -1,101 +0,0 @@
package v0
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/model"
)
// StartAuthInput represents the input for starting an auth flow
type StartAuthInput struct {
Body struct {
Method string `json:"method" doc:"Authentication method" example:"github"`
RepoRef string `json:"repo_ref" doc:"Repository reference" example:"owner/repo"`
}
}
// StartAuthBody represents the auth flow start response body
type StartAuthBody struct {
AuthFlowInfo map[string]string `json:"auth_flow_info" doc:"Authentication flow information"`
StatusToken string `json:"status_token" doc:"Token to check auth status"`
}
// CheckAuthStatusInput represents the input for checking auth status
type CheckAuthStatusInput struct {
StatusToken string `path:"token" doc:"Status token from auth flow start"`
}
// CheckAuthStatusBody represents the auth status response body
type CheckAuthStatusBody struct {
Status string `json:"status" doc:"Authentication status" example:"pending"`
}
// RegisterAuthEndpoints registers all auth-related endpoints
func RegisterAuthEndpoints(api huma.API, authService auth.Service) {
// Start auth flow endpoint
huma.Register(api, huma.Operation{
OperationID: "start-auth",
Method: http.MethodPost,
Path: "/v0/auth/start",
Summary: "Start authentication flow",
Description: "Start an authentication flow for publishing servers",
Tags: []string{"auth"},
}, func(ctx context.Context, input *StartAuthInput) (*Response[StartAuthBody], error) {
// Validate required fields
if input.Body.Method == "" {
return nil, huma.Error400BadRequest("Auth method is required")
}
if input.Body.RepoRef == "" {
return nil, huma.Error400BadRequest("Repository reference is required")
}
// Convert string method to AuthMethod type
var authMethod model.AuthMethod
switch input.Body.Method {
case "github":
authMethod = model.AuthMethodGitHub
case "none":
authMethod = model.AuthMethodNone
default:
return nil, huma.Error400BadRequest("Invalid auth method: " + input.Body.Method)
}
// Start the auth flow
flowInfo, statusToken, err := authService.StartAuthFlow(ctx, authMethod, input.Body.RepoRef)
if err != nil {
return nil, huma.Error500InternalServerError("Failed to start auth flow", err)
}
return &Response[StartAuthBody]{
Body: StartAuthBody{
AuthFlowInfo: flowInfo,
StatusToken: statusToken,
},
}, nil
})
// Check auth status endpoint
huma.Register(api, huma.Operation{
OperationID: "check-auth-status",
Method: http.MethodGet,
Path: "/v0/auth/status/{token}",
Summary: "Check authentication status",
Description: "Check the status of an ongoing authentication flow",
Tags: []string{"auth"},
}, func(ctx context.Context, input *CheckAuthStatusInput) (*Response[CheckAuthStatusBody], error) {
// Check the auth status
status, err := authService.CheckAuthStatus(ctx, input.StatusToken)
if err != nil {
return nil, huma.Error404NotFound("Auth flow not found or expired")
}
return &Response[CheckAuthStatusBody]{
Body: CheckAuthStatusBody{
Status: status,
},
}, nil
})
}

View file

@ -0,0 +1,204 @@
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, 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
}
// getGitHubUserOrgs gets the authenticated user's organizations
func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, token string) ([]GitHubUserOrOrg, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/user/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)
}

View 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 = "/user/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.AuthMethodGitHub, 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 githubOrgsEndpoint:
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: "validuser",
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, "validuser", 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.AuthMethodGitHub,
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.AuthMethodGitHub, 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.AuthMethodGitHub,
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.AuthMethodGitHub,
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 githubOrgsEndpoint:
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 "/user/orgs":
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)
}
}

View file

@ -0,0 +1,19 @@
package auth
import (
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/config"
)
// RegisterAuthEndpoints registers all authentication endpoints
func RegisterAuthEndpoints(api huma.API, cfg *config.Config) {
// Register GitHub authentication endpoint
RegisterGitHubEndpoint(api, cfg)
// Register anonymous authentication endpoint
RegisterNoneEndpoint(api, cfg)
// Future auth providers can be registered here:
// RegisterGitLabEndpoint(api, cfg)
// RegisterOIDCEndpoint(api, cfg)
}

View file

@ -0,0 +1,84 @@
package auth
import (
"context"
"fmt"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
)
// NoneHandler handles anonymous authentication
type NoneHandler struct {
config *config.Config
jwtManager *auth.JWTManager
}
// NewNoneHandler creates a new anonymous authentication handler
func NewNoneHandler(cfg *config.Config) *NoneHandler {
return &NoneHandler{
config: cfg,
jwtManager: auth.NewJWTManager(cfg),
}
}
// RegisterNoneEndpoint registers the anonymous authentication endpoint
func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
if !cfg.EnableAnonymousAuth {
return
}
handler := NewNoneHandler(cfg)
// Anonymous token endpoint
huma.Register(api, huma.Operation{
OperationID: "get-anonymous-token",
Method: http.MethodPost,
Path: "/v0/auth/none",
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) {
response, err := handler.GetAnonymousToken(ctx)
if err != nil {
return nil, huma.Error500InternalServerError("Failed to generate token", err)
}
return &struct {
Body auth.TokenResponse `json:"body"`
}{
Body: *response,
}, nil
})
}
// GetAnonymousToken generates an anonymous Registry JWT token
func (h *NoneHandler) GetAnonymousToken(ctx context.Context) (*auth.TokenResponse, error) {
// Build permissions for anonymous namespace only
permissions := []auth.Permission{
{
Action: auth.PermissionActionPublish,
ResourcePattern: "io.modelcontextprotocol.anonymous/*",
},
}
// Create JWT claims for anonymous user
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodNone,
AuthMethodSubject: "anonymous",
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
}

View file

@ -0,0 +1,51 @@
package auth_test
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"testing"
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"
)
func TestNoneHandler_GetAnonymousToken(t *testing.T) {
// Generate a proper Ed25519 seed for testing
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableAnonymousAuth: true,
}
handler := v0auth.NewNoneHandler(cfg)
ctx := context.Background()
// Test getting anonymous token
tokenResponse, err := handler.GetAnonymousToken(ctx)
require.NoError(t, err)
assert.NotEmpty(t, tokenResponse.RegistryToken)
assert.Greater(t, tokenResponse.ExpiresAt, 0)
// Validate the token claims
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
require.NoError(t, err)
// Check auth method
assert.Equal(t, model.AuthMethodNone, claims.AuthMethod)
assert.Equal(t, "anonymous", claims.AuthMethodSubject)
// Check permissions
require.Len(t, claims.Permissions, 1)
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
assert.Equal(t, "io.modelcontextprotocol.anonymous/*", claims.Permissions[0].ResourcePattern)
}

View file

@ -49,7 +49,7 @@ func TestHealthEndpoint(t *testing.T) {
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the health endpoint
v0.RegisterHealthEndpoint(api, tc.config)
@ -67,7 +67,7 @@ func TestHealthEndpoint(t *testing.T) {
// Since Huma adds a $schema field, we'll check individual fields
body := w.Body.String()
assert.Contains(t, body, `"status":"ok"`)
if tc.config.GithubClientID != "" {
assert.Contains(t, body, `"github_client_id":"test-github-client-id"`)
} else {
@ -75,4 +75,4 @@ func TestHealthEndpoint(t *testing.T) {
}
})
}
}
}

View file

@ -15,7 +15,7 @@ func TestPingEndpoint(t *testing.T) {
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the ping endpoint
v0.RegisterPingEndpoint(api)
@ -32,4 +32,4 @@ func TestPingEndpoint(t *testing.T) {
// Check the response body contains pong
body := w.Body.String()
assert.Contains(t, body, `"pong":true`)
}
}

View file

@ -7,19 +7,22 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/service"
)
// PublishServerInput represents the input for publishing a server
type PublishServerInput struct {
Authorization string `header:"Authorization" doc:"GitHub OAuth token" required:"true"`
Authorization string `header:"Authorization" doc:"Registry JWT token (obtained from /v0/auth/token/github)" required:"true"`
Body model.PublishRequest
}
// RegisterPublishEndpoint registers the publish endpoint
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, authService auth.Service) {
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg *config.Config) {
// Create JWT manager for token validation
jwtManager := auth.NewJWTManager(cfg)
huma.Register(api, huma.Operation{
OperationID: "publish-server",
Method: http.MethodPost,
@ -39,40 +42,18 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, aut
}
token := authHeader[len(bearerPrefix):]
// Validate Registry JWT token
claims, err := jwtManager.ValidateToken(ctx, token)
if err != nil {
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
}
// Convert PublishRequest body to ServerDetail
serverDetail := input.Body.ServerDetail
// Huma handles validation automatically based on struct tags
// But we can add custom validation if needed
if serverDetail.Name == "" {
return nil, huma.Error400BadRequest("Name is required")
}
if serverDetail.VersionDetail.Version == "" {
return nil, huma.Error400BadRequest("Version is required")
}
// Determine authentication method based on server name prefix
var authMethod model.AuthMethod
if strings.HasPrefix(serverDetail.Name, "io.github") {
authMethod = model.AuthMethodGitHub
} else {
authMethod = model.AuthMethodNone
}
// Setup authentication info
a := model.Authentication{
Method: authMethod,
Token: token,
RepoRef: serverDetail.Name,
}
// Validate authentication
valid, err := authService.ValidateAuth(ctx, a)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if !valid {
return nil, huma.Error401Unauthorized("Invalid authentication credentials")
// Verify that the token's repository matches the server being published
if !jwtManager.HasPermission(serverDetail.Name, auth.PermissionActionPublish, claims.Permissions) {
return nil, huma.Error403Forbidden("You do not have permission to publish this server")
}
// Publish the server details

View file

@ -2,6 +2,10 @@ package v0_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
@ -10,25 +14,43 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
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"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// Helper function to generate a valid JWT token for testing
func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
jwtManager := auth.NewJWTManager(cfg)
ctx := context.Background()
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
if err != nil {
return "", err
}
return tokenResponse.RegistryToken, nil
}
func TestPublishIntegration(t *testing.T) {
// Setup fake service and auth service
// Setup fake service
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create test config with a valid Ed25519 seed
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
v0.RegisterPublishEndpoint(api, registryService, testConfig)
t.Run("successful publish with GitHub auth", func(t *testing.T) {
publishReq := model.PublishRequest{
@ -45,115 +67,52 @@ func TestPublishIntegration(t *testing.T) {
Version: "1.0.0",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "test-mcp-server",
Version: "1.0.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "config",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Configuration file path",
Format: model.FormatFilePath,
IsRequired: true,
},
},
},
},
},
},
Remotes: []model.Remote{
{
TransportType: "http",
URL: "http://localhost:3000/mcp",
},
},
// Packages field removed as it depends on the actual model.Package structure
},
}
// Marshal the server detail to JSON
jsonData, err := json.Marshal(publishReq)
// Generate valid JWT token
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethodSubject: "testuser",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
},
}
token, err := generateIntegrationTestJWTToken(testConfig, claims)
require.NoError(t, err)
// Create a request
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_test_token_123")
req.Header.Set("Authorization", "Bearer "+token)
// Create a response recorder
recorder := httptest.NewRecorder()
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Call the handler through the mux
mux.ServeHTTP(recorder, req)
// Check the response
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, http.StatusOK, rr.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.ID, "Server ID should be generated")
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.Description, response.Description)
// Verify the server was actually published by retrieving it
publishedServer, err := registryService.GetByID(response.ID)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, publishedServer.Name)
assert.Equal(t, publishReq.Description, publishedServer.Description)
assert.Equal(t, publishReq.VersionDetail.Version, publishedServer.VersionDetail.Version)
assert.Len(t, publishedServer.Packages, 1)
assert.Len(t, publishedServer.Remotes, 1)
assert.Equal(t, publishReq.VersionDetail.Version, response.VersionDetail.Version)
})
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "custom-mcp-server",
Description: "A custom MCP server without auth",
Name: "test-mcp-server-no-auth",
Description: "A test MCP server without authentication",
Repository: model.Repository{
URL: "https://example.com/custom-server",
Source: "custom",
ID: "custom/custom-server",
URL: "https://example.com/test-mcp-server",
Source: "example",
ID: "test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer dummy_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.ID, "Server ID should be generated")
})
t.Run("publish fails with missing name", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "", // Missing name
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
@ -161,52 +120,86 @@ func TestPublishIntegration(t *testing.T) {
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Name is required")
})
t.Run("publish fails with missing version", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "", // Missing version
},
},
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
jsonData, err := json.Marshal(publishReq)
token, err := generateIntegrationTestJWTToken(testConfig, claims)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Version is required")
assert.Equal(t, http.StatusOK, rr.Code)
var response model.Server
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, response.Name)
})
t.Run("publish fails with missing authorization header", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
Name: "test-server",
},
},
}
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// No Authorization header
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnprocessableEntity, rr.Code)
assert.Contains(t, rr.Body.String(), "required header parameter is missing")
})
t.Run("publish fails with invalid token", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
},
},
}
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer invalid-token")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
assert.Contains(t, rr.Body.String(), "Invalid or expired Registry JWT token")
})
t.Run("publish fails when permission denied", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
@ -215,517 +208,27 @@ func TestPublishIntegration(t *testing.T) {
},
}
jsonData, err := json.Marshal(publishReq)
// Generate valid JWT token but with different permissions
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.myuser/*"},
},
}
token, err := generateIntegrationTestJWTToken(testConfig, claims)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// No Authorization header
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
assert.Contains(t, recorder.Body.String(), "required header parameter is missing")
})
t.Run("publish fails with invalid JSON", func(t *testing.T) {
invalidJSON := `{"name": "test", "version": `
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBufferString(invalidJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "unexpected end of JSON")
})
t.Run("publish fails with unsupported HTTP method", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v0/publish", nil)
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Method Not Allowed")
})
t.Run("publish fails with duplicate name and version", func(t *testing.T) {
// First, publish a server successfully
firstServerDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.duplicate/test-server",
Description: "First server for duplicate test",
Repository: model.Repository{
URL: "https://github.com/duplicate/test-server",
Source: "github",
ID: "duplicate/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(firstServerDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_first")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "First publish should succeed")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstServerID := response.ID // Store the ID for later verification
// Now try to publish another server with the same name and version
duplicateServerDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.duplicate/test-server", // Same name
Description: "Duplicate server attempt",
Repository: model.Repository{
URL: "https://github.com/duplicate/test-server-fork",
Source: "github",
ID: "duplicate/test-server-fork",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0", // Same version
},
},
},
}
duplicateJSONData, err := json.Marshal(duplicateServerDetail)
require.NoError(t, err)
duplicateReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(duplicateJSONData))
duplicateReq.Header.Set("Content-Type", "application/json")
duplicateReq.Header.Set("Authorization", "Bearer github_token_duplicate")
duplicateRecorder := httptest.NewRecorder()
mux.ServeHTTP(duplicateRecorder, duplicateReq)
// The duplicate should fail
assert.Equal(t, http.StatusInternalServerError, duplicateRecorder.Code)
assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server")
// Verify that only the first server was actually stored
retrievedServer, err := registryService.GetByID(firstServerID)
require.NoError(t, err)
assert.Equal(t, firstServerDetail.Name, retrievedServer.Name)
assert.Equal(t, firstServerDetail.Description, retrievedServer.Description)
})
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
// Publish first version
firstVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioned/test-server",
Description: "First version of the server",
Repository: model.Repository{
URL: "https://github.com/versioned/test-server",
Source: "github",
ID: "versioned/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(firstVersionDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_v1")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "First version should succeed")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstVersionID := response.ID // Store the ID for later verification
require.NotEmpty(t, firstVersionID, "Server ID should be generated")
// Publish second version with same name but different version
secondVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioned/test-server", // Same name
Description: "Second version of the server",
Repository: model.Repository{
URL: "https://github.com/versioned/test-server",
Source: "github",
ID: "versioned/test-server",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0", // Different version
},
},
},
}
secondJSONData, err := json.Marshal(secondVersionDetail)
require.NoError(t, err)
secondReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(secondJSONData))
secondReq.Header.Set("Content-Type", "application/json")
secondReq.Header.Set("Authorization", "Bearer github_token_v2")
secondRecorder := httptest.NewRecorder()
mux.ServeHTTP(secondRecorder, secondReq)
// The second version should succeed
assert.Equal(t, http.StatusOK, secondRecorder.Code)
var secondResponse model.Server
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
require.NoError(t, err)
secondVersionID := secondResponse.ID // Store the ID for later verification
require.NotEmpty(t, secondVersionID, "Server ID for second version should be generated")
// Verify both versions exist
firstRetrieved, err := registryService.GetByID(firstVersionID)
require.NoError(t, err)
assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version)
secondRetrieved, err := registryService.GetByID(secondVersionID)
require.NoError(t, err)
assert.Equal(t, "2.0.0", secondRetrieved.VersionDetail.Version)
})
t.Run("publish fails when trying to publish older version after newer version", func(t *testing.T) {
// First, publish a newer version (2.0.0)
newerVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioning/version-order-test",
Description: "Newer version published first",
Repository: model.Repository{
URL: "https://github.com/versioning/version-order-test",
Source: "github",
ID: "versioning/version-order-test",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
},
},
}
jsonData, err := json.Marshal(newerVersionDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_newer")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "Newer version should be published successfully")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
newerVersionID := response.ID // Store the ID for later verification
require.NotEmpty(t, newerVersionID, "Server ID for newer version should be generated")
// Now try to publish an older version (1.0.0) of the same package
olderVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioning/version-order-test", // Same name
Description: "Older version published after newer",
Repository: model.Repository{
URL: "https://github.com/versioning/version-order-test",
Source: "github",
ID: "versioning/version-order-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0", // Older version
},
},
},
}
olderJSONData, err := json.Marshal(olderVersionDetail)
require.NoError(t, err)
olderReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(olderJSONData))
olderReq.Header.Set("Content-Type", "application/json")
olderReq.Header.Set("Authorization", "Bearer github_token_older")
olderRecorder := httptest.NewRecorder()
mux.ServeHTTP(olderRecorder, olderReq)
// This should fail - we shouldn't allow publishing older versions after newer ones
assert.Equal(t, http.StatusInternalServerError, olderRecorder.Code, "Publishing older version should fail")
assert.Contains(t, olderRecorder.Body.String(), "Failed to publish server", "Error message should mention version")
// Verify that only the newer version exists
newerRetrieved, err := registryService.GetByID(newerVersionID)
require.NoError(t, err)
assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), "You do not have permission to publish this server")
})
}
func TestPublishIntegrationEndToEnd(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
t.Run("end-to-end publish and retrieve flow", func(t *testing.T) {
// Step 1: Get initial count of servers
initialServers, _, err := registryService.List("", 100)
require.NoError(t, err)
initialCount := len(initialServers)
// Step 2: Publish a new server
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.e2e/end-to-end-server",
Description: "End-to-end test server",
Repository: model.Repository{
URL: "https://github.com/e2e/end-to-end-server",
Source: "github",
ID: "e2e/end-to-end-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_e2e_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverID := response.ID
require.Equal(t, http.StatusOK, recorder.Code)
// Step 3: Verify the count increased
updatedServers, _, err := registryService.List("", 100)
require.NoError(t, err)
assert.Equal(t, initialCount+1, len(updatedServers))
// Step 4: Verify the server can be retrieved by ID
retrievedServer, err := registryService.GetByID(serverID)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, retrievedServer.Name)
assert.Equal(t, publishReq.Description, retrievedServer.Description)
// Step 5: Verify the server appears in the list
found := false
for _, server := range updatedServers {
if server.ID == serverID {
found = true
assert.Equal(t, publishReq.Name, server.Name)
break
}
}
assert.True(t, found, "Published server should appear in the list")
})
}
func TestPublishIntegrationWithComplexPackages(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
t.Run("publish with complex package configuration", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.complex/advanced-mcp-server",
Description: "An advanced MCP server with complex configuration",
Repository: model.Repository{
URL: "https://github.com/complex/advanced-mcp-server",
Source: "github",
ID: "complex/advanced-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "2.1.0",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "@example/advanced-mcp-server",
Version: "43.1.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "experimental-modules",
},
{
Type: model.ArgumentTypeNamed,
Name: "config",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Main configuration file",
Format: model.FormatFilePath,
IsRequired: true,
Default: "./config.json",
},
},
},
{
Type: model.ArgumentTypePositional,
Name: "mode",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Operation mode",
Format: model.FormatString,
IsRequired: false,
Default: "production",
Choices: []string{"development", "staging", "production"},
},
},
},
},
PackageArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "install-deps",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Install dependencies",
Format: model.FormatBoolean,
Default: "true",
},
},
},
},
EnvironmentVariables: []model.KeyValueInput{
{
Name: "LOG_LEVEL",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Logging level",
Format: model.FormatString,
Default: "info",
Choices: []string{"debug", "info", "warn", "error"},
},
},
},
{
Name: "API_KEY",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "API key for external service",
Format: model.FormatString,
IsRequired: true,
IsSecret: true,
},
},
},
},
},
},
Remotes: []model.Remote{
{
TransportType: "http",
URL: "http://localhost:8080/mcp",
Headers: []model.KeyValueInput{
{
Name: "API-Version",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "API Version Header",
Format: model.FormatString,
Value: "v1",
},
},
},
},
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_complex_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverID := response.ID
assert.NotEmpty(t, serverID, "Server ID should be generated")
// Verify the complex server was published correctly
publishedServer, err := registryService.GetByID(serverID)
require.NoError(t, err)
// Verify package details
require.Len(t, publishedServer.Packages, 1)
pkg := publishedServer.Packages[0]
assert.Equal(t, "npm", pkg.RegistryName)
assert.Equal(t, "@example/advanced-mcp-server", pkg.Name)
assert.Len(t, pkg.RuntimeArguments, 3)
assert.Len(t, pkg.PackageArguments, 1)
assert.Len(t, pkg.EnvironmentVariables, 2)
// Verify remotes
require.Len(t, publishedServer.Remotes, 1)
assert.Equal(t, "http", publishedServer.Remotes[0].TransportType)
assert.Len(t, publishedServer.Remotes[0].Headers, 1)
})
}

View file

@ -3,7 +3,11 @@ package v0_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@ -12,9 +16,11 @@ import (
"github.com/danielgtaylor/huma/v2/adapters/humago"
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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockRegistryService is a mock implementation of the RegistryService interface
@ -40,35 +46,33 @@ func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error {
return args.Error(0)
}
// MockAuthService is a mock implementation of the auth.Service interface
type MockAuthService struct {
mock.Mock
}
func (m *MockAuthService) StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) {
args := m.Called(ctx, method, repoRef)
return args.Get(0).(map[string]string), args.String(1), args.Error(2)
}
func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) {
args := m.Called(ctx, statusToken)
return args.String(0), args.Error(1)
}
func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) {
args := m.Called(ctx, authentication)
return args.Bool(0), args.Error(1)
// Helper function to generate a valid JWT token for testing
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
jwtManager := auth.NewJWTManager(cfg)
ctx := context.Background()
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
if err != nil {
return "", err
}
return tokenResponse.RegistryToken, nil
}
func TestPublishEndpoint(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
testCases := []struct {
name string
requestBody interface{}
authHeader string
setupMocks func(*MockRegistryService, *MockAuthService)
expectedStatus int
expectedResponse string
expectedError string
name string
requestBody interface{}
tokenClaims *auth.JWTClaims
authHeader string
setupMocks func(*MockRegistryService)
expectedStatus int
expectedError string
}{
{
name: "successful publish with GitHub auth",
@ -91,13 +95,14 @@ func TestPublishEndpoint(t *testing.T) {
},
},
},
authHeader: "Bearer github_token_123",
setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, model.Authentication{
Method: model.AuthMethodGitHub,
Token: "github_token_123",
RepoRef: "io.github.example/test-server",
}).Return(true, nil)
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethodSubject: "example",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
},
expectedStatus: http.StatusOK,
@ -123,13 +128,13 @@ func TestPublishEndpoint(t *testing.T) {
},
},
},
authHeader: "Bearer some_token",
setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, model.Authentication{
Method: model.AuthMethodNone,
Token: "some_token",
RepoRef: "example/test-server",
}).Return(true, nil)
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
},
expectedStatus: http.StatusOK,
@ -137,8 +142,8 @@ func TestPublishEndpoint(t *testing.T) {
{
name: "missing authorization header",
requestBody: model.PublishRequest{},
authHeader: "",
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
authHeader: "", // Empty auth header
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "required header parameter is missing",
},
@ -146,212 +151,92 @@ func TestPublishEndpoint(t *testing.T) {
name: "invalid authorization header format",
requestBody: model.PublishRequest{},
authHeader: "InvalidFormat",
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid Authorization header format",
},
{
name: "missing server name",
name: "invalid token",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "", // Missing name
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
},
authHeader: "Bearer token",
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Name is required",
},
{
name: "missing version",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "", // Missing version
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
Version: "1.0.0",
},
},
},
},
authHeader: "Bearer token",
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Version is required",
},
{
name: "authentication required error",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
},
authHeader: "Bearer token",
setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired)
},
authHeader: "Bearer invalidToken",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Authentication failed",
expectedError: "Invalid or expired Registry JWT token",
},
{
name: "authentication failed",
name: "permission denied",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "test-server",
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
Version: "1.0.0",
},
},
},
},
authHeader: "Bearer invalid_token",
setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil)
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid authentication credentials",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusForbidden,
expectedError: "You do not have permission to publish this server",
},
{
name: "registry service error",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "test-server",
Name: "example/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
Version: "1.0.0",
},
},
},
},
authHeader: "Bearer token",
setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(true, nil)
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(assert.AnError)
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(errors.New("database error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to publish server",
},
{
name: "method not allowed",
requestBody: nil,
authHeader: "",
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
expectedStatus: http.StatusMethodNotAllowed,
expectedError: "Method Not Allowed",
},
{
name: "HTML injection attack in name field",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id-html",
Name: "io.github.malicious/<script>alert('XSS')</script>test-server",
Description: "A test server with HTML injection attempt",
Repository: model.Repository{
URL: "https://github.com/malicious/test-server",
Source: "github",
ID: "malicious/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
},
authHeader: "Bearer github_token_123",
setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {
// The implementation should escape HTML
authSvc.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool {
return auth.Method == model.AuthMethodGitHub &&
auth.Token == "github_token_123" &&
auth.RepoRef == "io.github.malicious/<script>alert('XSS')</script>test-server"
})).Return(true, nil)
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
},
expectedStatus: http.StatusOK,
},
{
name: "HTML injection attack in name field with non-GitHub prefix",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id-html-non-github",
Name: "malicious.com/<script>alert('XSS')</script>test-server",
Description: "A test server with HTML injection attempt (non-GitHub)",
Repository: model.Repository{
URL: "https://malicious.com/test-server",
Source: "custom",
ID: "malicious/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
},
authHeader: "Bearer some_token",
setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {
authSvc.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool {
return auth.Method == model.AuthMethodNone &&
auth.Token == "some_token" &&
auth.RepoRef == "malicious.com/<script>alert('XSS')</script>test-server"
})).Return(true, nil)
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
},
expectedStatus: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mocks
mockRegistry := new(MockRegistryService)
mockAuthService := new(MockAuthService)
// Setup mocks
tc.setupMocks(mockRegistry, mockAuthService)
tc.setupMocks(mockRegistry)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
// Register the endpoint with test config
v0.RegisterPublishEndpoint(api, mockRegistry, testConfig)
// Prepare request body
var requestBody []byte
@ -362,178 +247,33 @@ func TestPublishEndpoint(t *testing.T) {
}
// Create request
method := http.MethodPost
if tc.name == "method not allowed" {
method = http.MethodGet
}
req, err := http.NewRequestWithContext(context.Background(), method, "/v0/publish", bytes.NewBuffer(requestBody))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
assert.NoError(t, err)
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Content-Type", "application/json")
// Set auth header if provided
// Set auth header
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
} else if tc.tokenClaims != nil {
// Generate a valid JWT token
token, err := generateTestJWTToken(testConfig, *tc.tokenClaims)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
}
// Create response recorder
// Perform request
rr := httptest.NewRecorder()
// Call the handler through the mux
mux.ServeHTTP(rr, req)
// Check status code
assert.Equal(t, tc.expectedStatus, rr.Code)
// Assertions
assert.Equal(t, tc.expectedStatus, rr.Code, "status code mismatch")
if tc.expectedError != "" {
// Check that the error message is contained in the response
assert.Contains(t, rr.Body.String(), tc.expectedError)
}
// Assert that all expectations were met
// Verify mock expectations
mockRegistry.AssertExpectations(t)
mockAuthService.AssertExpectations(t)
})
}
}
func TestPublishEndpointBearerTokenParsing(t *testing.T) {
testCases := []struct {
name string
authHeader string
expectedToken string
}{
{
name: "bearer token with Bearer prefix",
authHeader: "Bearer github_token_123",
expectedToken: "github_token_123",
},
{
name: "bearer token with bearer prefix (lowercase)",
authHeader: "bearer github_token_123",
expectedToken: "github_token_123",
},
{
name: "mixed case Bearer prefix",
authHeader: "BeArEr github_token_123",
expectedToken: "github_token_123",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockRegistry := new(MockRegistryService)
mockAuthService := new(MockAuthService)
// Setup mock to capture the actual token passed
mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool {
return auth.Token == tc.expectedToken
})).Return(true, nil)
mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
serverDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
}
requestBody, err := json.Marshal(serverDetail)
assert.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
assert.NoError(t, err)
req.Header.Set("Authorization", tc.authHeader)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
mockAuthService.AssertExpectations(t)
})
}
}
func TestPublishEndpointAuthMethodSelection(t *testing.T) {
testCases := []struct {
name string
serverName string
expectedAuthMethod model.AuthMethod
}{
{
name: "GitHub prefix triggers GitHub auth",
serverName: "io.github.example/test-server",
expectedAuthMethod: model.AuthMethodGitHub,
},
{
name: "non-GitHub prefix uses no auth",
serverName: "example.com/test-server",
expectedAuthMethod: model.AuthMethodNone,
},
{
name: "empty prefix uses no auth",
serverName: "test-server",
expectedAuthMethod: model.AuthMethodNone,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockRegistry := new(MockRegistryService)
mockAuthService := new(MockAuthService)
// Setup mock to capture the auth method
mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool {
return auth.Method == tc.expectedAuthMethod
})).Return(true, nil)
mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
serverDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: tc.serverName,
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
},
}
requestBody, err := json.Marshal(serverDetail)
assert.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer test_token")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
mockAuthService.AssertExpectations(t)
})
}
}

View file

@ -20,4 +20,4 @@ type Response[T any] struct {
// return &Response[HealthBody]{
// Body: HealthBody{...},
// }, nil
// }
// }

View file

@ -34,7 +34,6 @@ type ServerDetailInput struct {
ID string `path:"id" doc:"Server ID (UUID)" format:"uuid"`
}
// RegisterServersEndpoints registers all server-related endpoints
func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
// List servers endpoint
@ -64,7 +63,7 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
body := ListServersBody{
Servers: servers,
}
// Add metadata if there's a next cursor
if nextCursor != "" {
body.Metadata = &Metadata{
@ -100,4 +99,4 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
Body: *serverDetail,
}, nil
})
}
}

View file

@ -146,11 +146,11 @@ func TestServersListEndpoint(t *testing.T) {
},
},
{
name: "successful list with limit capping at 100",
queryParams: "?limit=150",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
name: "successful list with limit capping at 100",
queryParams: "?limit=150",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
},
{
name: "invalid cursor parameter",
@ -408,7 +408,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
@ -440,7 +440,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
@ -465,4 +465,4 @@ func TestServersEndpointsIntegration(t *testing.T) {
// Verify mock expectations
mockRegistry.AssertExpectations(t)
}
}