From 12ab632d32d2f84a8d123008d11347c25a986a13 Mon Sep 17 00:00:00 2001 From: adam jones Date: Mon, 18 Aug 2025 22:05:06 +0100 Subject: [PATCH] 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 - [ ] 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 - [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 --- .../v0/auth/{github.go => github_at.go} | 21 +- .../{github_test.go => github_at_test.go} | 10 +- internal/api/handlers/v0/auth/github_oidc.go | 297 ++++++++++++++++++ .../api/handlers/v0/auth/github_oidc_test.go | 217 +++++++++++++ internal/api/handlers/v0/auth/main.go | 7 +- internal/api/handlers/v0/auth/none.go | 9 +- .../handlers/v0/publish_integration_test.go | 4 +- internal/api/handlers/v0/publish_test.go | 4 +- internal/auth/jwt_test.go | 10 +- internal/model/model.go | 6 +- tools/publisher/README.md | 81 +++-- tools/publisher/auth/README.md | 104 ------ .../auth/{github/oauth.go => github-at.go} | 28 +- tools/publisher/auth/github-oidc.go | 165 ++++++++++ tools/publisher/auth/{none => }/none.go | 18 +- tools/publisher/main.go | 18 +- 16 files changed, 804 insertions(+), 195 deletions(-) rename internal/api/handlers/v0/auth/{github.go => github_at.go} (91%) rename internal/api/handlers/v0/auth/{github_test.go => github_at_test.go} (98%) create mode 100644 internal/api/handlers/v0/auth/github_oidc.go create mode 100644 internal/api/handlers/v0/auth/github_oidc_test.go delete mode 100644 tools/publisher/auth/README.md rename tools/publisher/auth/{github/oauth.go => github-at.go} (92%) create mode 100644 tools/publisher/auth/github-oidc.go rename tools/publisher/auth/{none => }/none.go (77%) diff --git a/internal/api/handlers/v0/auth/github.go b/internal/api/handlers/v0/auth/github_at.go similarity index 91% rename from internal/api/handlers/v0/auth/github.go rename to internal/api/handlers/v0/auth/github_at.go index d94b2ea..d4ef7e0 100644 --- a/internal/api/handlers/v0/auth/github.go +++ b/internal/api/handlers/v0/auth/github_at.go @@ -9,6 +9,7 @@ import ( "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" @@ -42,29 +43,25 @@ func (h *GitHubHandler) SetBaseURL(url string) { h.baseURL = url } -// RegisterGitHubEndpoint registers the GitHub authentication endpoint -func RegisterGitHubEndpoint(api huma.API, cfg *config.Config) { +// 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", - Summary: "Exchange GitHub token for Registry JWT", - Description: "Exchange a GitHub OAuth token for a short-lived Registry JWT token", + 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) (*struct { - Body auth.TokenResponse `json:"body"` - }, error) { + }, 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 &struct { - Body auth.TokenResponse `json:"body"` - }{ + return &v0.Response[auth.TokenResponse]{ Body: *response, }, nil }) @@ -89,7 +86,7 @@ func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) ( // Create JWT claims with GitHub user info claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHub, + AuthMethod: model.AuthMethodGitHubAT, AuthMethodSubject: user.Login, Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/github_test.go b/internal/api/handlers/v0/auth/github_at_test.go similarity index 98% rename from internal/api/handlers/v0/auth/github_test.go rename to internal/api/handlers/v0/auth/github_at_test.go index 2403d24..c57beba 100644 --- a/internal/api/handlers/v0/auth/github_test.go +++ b/internal/api/handlers/v0/auth/github_at_test.go @@ -78,7 +78,7 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) { 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, 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) @@ -333,7 +333,7 @@ func TestJWTTokenValidation(t *testing.T) { t.Run("generate and validate token", func(t *testing.T) { // Create test claims claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHub, + AuthMethod: model.AuthMethodGitHubAT, AuthMethodSubject: "testuser", Permissions: []auth.Permission{ { @@ -351,7 +351,7 @@ func TestJWTTokenValidation(t *testing.T) { // Validate token validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodGitHub, validatedClaims.AuthMethod) + assert.Equal(t, model.AuthMethodGitHubAT, validatedClaims.AuthMethod) assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject) assert.Len(t, validatedClaims.Permissions, 1) }) @@ -360,7 +360,7 @@ func TestJWTTokenValidation(t *testing.T) { // Create claims with past expiration pastTime := time.Now().Add(-1 * time.Hour) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHub, + AuthMethod: model.AuthMethodGitHubAT, AuthMethodSubject: "testuser", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(pastTime), @@ -381,7 +381,7 @@ func TestJWTTokenValidation(t *testing.T) { t.Run("invalid signature", func(t *testing.T) { // Create test claims claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHub, + AuthMethod: model.AuthMethodGitHubAT, AuthMethodSubject: "testuser", } diff --git a/internal/api/handlers/v0/auth/github_oidc.go b/internal/api/handlers/v0/auth/github_oidc.go new file mode 100644 index 0000000..0cd8f96 --- /dev/null +++ b/internal/api/handlers/v0/auth/github_oidc.go @@ -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./* 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 +} diff --git a/internal/api/handlers/v0/auth/github_oidc_test.go b/internal/api/handlers/v0/auth/github_oidc_test.go new file mode 100644 index 0000000..c83470e --- /dev/null +++ b/internal/api/handlers/v0/auth/github_oidc_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/handlers/v0/auth/main.go b/internal/api/handlers/v0/auth/main.go index 543bfe0..c2750ab 100644 --- a/internal/api/handlers/v0/auth/main.go +++ b/internal/api/handlers/v0/auth/main.go @@ -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) diff --git a/internal/api/handlers/v0/auth/none.go b/internal/api/handlers/v0/auth/none.go index cdc6893..41e5288 100644 --- a/internal/api/handlers/v0/auth/none.go +++ b/internal/api/handlers/v0/auth/none.go @@ -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 }) diff --git a/internal/api/handlers/v0/publish_integration_test.go b/internal/api/handlers/v0/publish_integration_test.go index ce51948..e3a098d 100644 --- a/internal/api/handlers/v0/publish_integration_test.go +++ b/internal/api/handlers/v0/publish_integration_test.go @@ -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/*"}, }, diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 79a3d99..a311711 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -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/*"}, }, diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index e421a53..8aaf0ee 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -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{ { diff --git a/internal/model/model.go b/internal/model/model.go index da208e3..ad38bee 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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" ) diff --git a/tools/publisher/README.md b/tools/publisher/README.md index ad051ca..bb5d70b 100644 --- a/tools/publisher/README.md +++ b/tools/publisher/README.md @@ -23,11 +23,14 @@ The tool supports two main commands: ### Publishing a server ```bash -# Basic usage -./bin/mcp-publisher publish -registry-url -mcp-file +# Basic usage with GitHub OAuth (interactive authentication) +./bin/mcp-publisher publish --registry-url --mcp-file + +# Use GitHub Actions OIDC (for CI/CD pipelines) +./bin/mcp-publisher publish --registry-url --mcp-file --auth-method github-oidc # Force a new login even if a token exists -./bin/mcp-publisher publish -registry-url -mcp-file -login +./bin/mcp-publisher publish --registry-url --mcp-file --login ``` ### Creating a server.json file @@ -39,10 +42,13 @@ The tool supports two main commands: ### Command-line Arguments -- `-registry-url`: URL of the MCP registry (required) -- `-mcp-file`: Path to the MCP configuration file (required) -- `-login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file) -- `-auth-method`: Authentication method to use (default: github) +- `--registry-url`: URL of the MCP registry (required) +- `--mcp-file`: Path to the MCP configuration file (required) +- `--login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file) +- `--auth-method`: Authentication method to use (default: github-at) + - `github-at`: Interactive GitHub OAuth device flow authentication + - `github-oidc`: GitHub Actions OIDC authentication (for CI/CD) + - `none`: No authentication (for registry contributors testing locally) ## Creating a server.json file @@ -127,28 +133,59 @@ After creation, you may need to manually edit the file to: ## Authentication -The tool has been simplified to use **GitHub OAuth device flow authentication exclusively**. Previous versions supported multiple authentication methods, but this version focuses solely on GitHub OAuth for better security and user experience. +The tool supports multiple authentication methods to accommodate different use cases: + +### GitHub OAuth Device Flow (`github-at`) - Default + +For interactive use: 1. **Automatic Setup**: The tool automatically retrieves the GitHub Client ID from the registry's health endpoint 2. **First Run Authentication**: When first run (or with the `--login` flag), the tool initiates the GitHub device flow 3. **User Authorization**: You'll be provided with a URL and a verification code to enter on GitHub 4. **Token Storage**: After successful authentication, the tool saves the access token locally in `.mcpregistry_github_token` for future use -5. **Token Exchange**: The GitHub token is exchanged for a short-lived registry token, which is saved locally in `.mcpregistry_registry_token`. -5. **Secure Communication**: The registry token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls - -**Note**: Authentication is performed via GitHub OAuth App, which you must authorize for the respective resources (e.g., organization access if publishing organization repositories). - -## Publishing Example - -To publish an existing server.json file to the registry: - -1. Prepare your `server.json` file with your server details. See an example in [server.json](./server.json) -2. Run the publisher tool: +5. **Token Exchange**: The GitHub token is exchanged for a short-lived registry token, which is saved locally in `.mcpregistry_registry_token` +6. **Secure Communication**: The registry token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls ```bash -./bin/mcp-publisher publish --registry-url "https://mcp-registry.example.com" --mcp-file "./server.json" +./bin/mcp-publisher publish --registry-url --mcp-file --auth-method github-at ``` -3. Follow the authentication instructions in the terminal if prompted. +### GitHub Actions OIDC (`github-oidc`) -4. Upon successful publication, you'll see a confirmation message. +For CI/CD pipelines using GitHub Actions: + +1. **Prerequisites**: Your GitHub Actions workflow must have `id-token: write` permissions +2. **Token Exchange**: The OIDC token is exchanged directly for a registry token via `/v0/auth/github-oidc` +3. **No Storage**: No local token files are created; authentication is ephemeral per workflow run + +**Example GitHub Actions workflow:** + +```yaml +name: Publish MCP Server +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC token generation + contents: read # E.g. To read the server.json file from your Git repo + steps: + - uses: actions/checkout@v4 + - name: Publish to MCP Registry + run: | + ./bin/mcp-publisher publish \ + --registry-url "https://registry.modelcontextprotocol.org" \ + --mcp-file "./server.json" \ + --auth-method github-oidc +``` + +### No Authentication (`none`) + +Mainly for registry contributors, for testing locally: + +```bash +./bin/mcp-publisher publish --registry-url --mcp-file --auth-method none +``` diff --git a/tools/publisher/auth/README.md b/tools/publisher/auth/README.md deleted file mode 100644 index f8069c7..0000000 --- a/tools/publisher/auth/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Authentication System - -The publisher tool now uses an interface-based authentication system that allows for multiple authentication mechanisms. - -## Architecture - -### Provider Interface - -The `Provider` interface is defined in `auth/interface.go` and provides the following methods: - -- `GetToken(ctx context.Context) (string, error)` - Retrieves or generates an authentication token -- `NeedsLogin() bool` - Checks if a new login flow is required -- `Login(ctx context.Context) error` - Performs the authentication flow -- `Name() string` - Returns the name of the authentication provider - -### Available Authentication Providers - -#### 1. GitHub OAuth Provider -- **Location**: `auth/github/oauth.go` -- **Usage**: Uses GitHub's device flow for authentication -- **Example**: `github.NewOAuthProvider(forceLogin, registryURL)` - - -## How to Add New Authentication Providers - -1. Create a new package under `auth/` directory (e.g., `auth/custom/`) -2. Implement the `Provider` interface -3. Add any necessary configuration or initialization functions -4. Update the main application to use the new provider - -### Example Implementation - -```go -package custom - -import ( - "context" - "fmt" -) - -type CustomProvider struct { - // your custom fields -} - -func NewCustomProvider(config string) *CustomProvider { - return &CustomProvider{ - // initialize your provider - } -} - -func (cp *CustomProvider) GetToken(ctx context.Context) (string, error) { - // implement token retrieval logic - return "custom-token", nil -} - -func (cp *CustomProvider) NeedsLogin() bool { - // implement login check logic - return false -} - -func (cp *CustomProvider) Login(ctx context.Context) error { - // implement authentication flow - return nil -} - -func (cp *CustomProvider) Name() string { - return "custom-auth" -} -``` - -## Usage in Main Application - -The main application automatically selects the appropriate authentication provider: - -1. Uses `GitHub OAuth Provider` by default -2. Future providers can be added by extending the provider selection logic - -```go -// Create the appropriate auth provider based on configuration -var authProvider auth.Provider -switch authMethod { -case "github": - log.Println("Using GitHub OAuth for authentication") - authProvider = github.NewOAuthProvider(forceLogin, registryURL) -default: - log.Printf("Unsupported authentication method: %s\n", authMethod) - return -} - -// Check if login is needed and perform authentication -ctx := context.Background() -if authProvider.NeedsLogin() { - err := authProvider.Login(ctx) - if err != nil { - log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error()) - return - } -} - -// Get the token -token, err := authProvider.GetToken(ctx) -``` - -This design allows for easy extension and testing of different authentication mechanisms while maintaining a clean separation of concerns. diff --git a/tools/publisher/auth/github/oauth.go b/tools/publisher/auth/github-at.go similarity index 92% rename from tools/publisher/auth/github/oauth.go rename to tools/publisher/auth/github-at.go index bf8027c..364c3a7 100644 --- a/tools/publisher/auth/github/oauth.go +++ b/tools/publisher/auth/github-at.go @@ -1,4 +1,4 @@ -package github +package auth import ( "bytes" @@ -49,8 +49,8 @@ type StoredRegistryToken struct { ExpiresAt int64 `json:"expires_at"` } -// OAuthProvider implements the AuthProvider interface using GitHub's device flow -type OAuthProvider struct { +// GitHubATProvider implements the Provider interface using GitHub's device flow +type GitHubATProvider struct { clientID string forceLogin bool registryURL string @@ -62,16 +62,16 @@ type ServerHealthResponse struct { GitHubClientID string `json:"github_client_id"` } -// NewOAuthProvider creates a new GitHub OAuth provider -func NewOAuthProvider(forceLogin bool, registryURL string) *OAuthProvider { - return &OAuthProvider{ +// NewGitHubATProvider creates a new GitHub OAuth provider +func NewGitHubATProvider(forceLogin bool, registryURL string) Provider { //nolint:ireturn + return &GitHubATProvider{ forceLogin: forceLogin, registryURL: registryURL, } } // GetToken retrieves the registry JWT token (exchanges GitHub token if needed) -func (g *OAuthProvider) GetToken(ctx context.Context) (string, error) { +func (g *GitHubATProvider) GetToken(ctx context.Context) (string, error) { // Check if we have a valid registry token registryToken, err := readRegistryToken() if err == nil && registryToken != "" { @@ -100,7 +100,7 @@ func (g *OAuthProvider) GetToken(ctx context.Context) (string, error) { } // NeedsLogin checks if a new login is required -func (g *OAuthProvider) NeedsLogin() bool { +func (g *GitHubATProvider) NeedsLogin() bool { if g.forceLogin { return true } @@ -123,7 +123,7 @@ func (g *OAuthProvider) NeedsLogin() bool { } // Login performs the GitHub device flow authentication -func (g *OAuthProvider) Login(ctx context.Context) error { +func (g *GitHubATProvider) Login(ctx context.Context) error { // If clientID is not set, try to retrieve it from the server's health endpoint if g.clientID == "" { clientID, err := getClientID(ctx, g.registryURL) @@ -164,12 +164,12 @@ func (g *OAuthProvider) Login(ctx context.Context) error { } // Name returns the name of this auth provider -func (g *OAuthProvider) Name() string { +func (g *GitHubATProvider) Name() string { return "github" } // requestDeviceCode initiates the device authorization flow -func (g *OAuthProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) { +func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) { if g.clientID == "" { return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login") } @@ -217,7 +217,7 @@ func (g *OAuthProvider) requestDeviceCode(ctx context.Context) (string, string, } // pollForToken polls for access token after user completes authorization -func (g *OAuthProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) { +func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) { if g.clientID == "" { return "", fmt.Errorf("GitHub Client ID is required for device flow login") } @@ -344,7 +344,7 @@ func getClientID(ctx context.Context, registryURL string) (string, error) { } // exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token -func (g *OAuthProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) { +func (g *GitHubATProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) { if g.registryURL == "" { return "", 0, fmt.Errorf("registry URL is required for token exchange") } @@ -360,7 +360,7 @@ func (g *OAuthProvider) exchangeTokenForRegistry(ctx context.Context, githubToke } // Make the token exchange request - exchangeURL := g.registryURL + "/v0/auth/github" + exchangeURL := g.registryURL + "/v0/auth/github-at" req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData)) if err != nil { return "", 0, fmt.Errorf("failed to create request: %w", err) diff --git a/tools/publisher/auth/github-oidc.go b/tools/publisher/auth/github-oidc.go new file mode 100644 index 0000000..f43bb08 --- /dev/null +++ b/tools/publisher/auth/github-oidc.go @@ -0,0 +1,165 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +type GitHubOIDCProvider struct { + registryURL string +} + +func NewGitHubOIDCProvider(registryURL string) Provider { //nolint:ireturn + return &GitHubOIDCProvider{ + registryURL: registryURL, + } +} + +// GetToken retrieves the registry JWT token using GitHub Actions OIDC token +func (o *GitHubOIDCProvider) GetToken(ctx context.Context) (string, error) { + // Get OIDC token from GitHub Actions endpoint + oidcToken, err := o.getOIDCTokenFromGitHub(ctx) + if err != nil { + return "", fmt.Errorf("failed to get OIDC token from GitHub: %w", err) + } + + // Exchange OIDC token for registry token + registryToken, err := o.exchangeOIDCTokenForRegistry(ctx, oidcToken) + if err != nil { + return "", fmt.Errorf("failed to exchange OIDC token: %w", err) + } + + return registryToken, nil +} + +// NeedsLogin always returns false for OIDC since the token is provided by GitHub Actions +func (o *GitHubOIDCProvider) NeedsLogin() bool { + // OIDC tokens are provided by GitHub Actions runtime, no interactive login needed + return false +} + +// Login is not needed for OIDC since tokens are provided by GitHub Actions +func (o *GitHubOIDCProvider) Login(_ context.Context) error { + // No interactive login needed for OIDC + return nil +} + +// Name returns the name of this auth provider +func (o *GitHubOIDCProvider) Name() string { + return "github-oidc" +} + +// exchangeOIDCTokenForRegistry exchanges a GitHub OIDC token for a registry JWT token +func (o *GitHubOIDCProvider) exchangeOIDCTokenForRegistry(ctx context.Context, oidcToken string) (string, error) { + if o.registryURL == "" { + return "", fmt.Errorf("registry URL is required for token exchange") + } + + // Prepare the request body + payload := map[string]string{ + "oidc_token": oidcToken, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Make the token exchange request + exchangeURL := o.registryURL + "/v0/auth/github-oidc" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body) + } + + var tokenResp RegistryTokenResponse + err = json.Unmarshal(body, &tokenResp) + if err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + return tokenResp.RegistryToken, nil +} + +// getOIDCTokenFromGitHub fetches the OIDC token from GitHub Actions endpoint +func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context) (string, error) { + // Check for required environment variables + requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if requestToken == "" { + return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable not found - are you running in GitHub Actions with id-token: write permissions?") + } + + requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + if requestURL == "" { + return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL environment variable not found - are you running in GitHub Actions with id-token: write permissions?") + } + + // Build the full URL with audience parameter + fullURL := requestURL + "&audience=mcp-registry" + + // Create the request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Set the authorization header + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json") + + // Make the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read the response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub OIDC token request failed with status %d: %s", resp.StatusCode, body) + } + + // Parse the response to extract the token value + var tokenResp struct { + Value string `json:"value"` + } + err = json.Unmarshal(body, &tokenResp) + if err != nil { + return "", fmt.Errorf("failed to unmarshal OIDC token response: %w", err) + } + + if tokenResp.Value == "" { + return "", fmt.Errorf("OIDC token value is empty in response") + } + + return tokenResp.Value, nil +} diff --git a/tools/publisher/auth/none/none.go b/tools/publisher/auth/none.go similarity index 77% rename from tools/publisher/auth/none/none.go rename to tools/publisher/auth/none.go index 6200dfb..1f80ecc 100644 --- a/tools/publisher/auth/none/none.go +++ b/tools/publisher/auth/none.go @@ -1,4 +1,4 @@ -package none +package auth import ( "context" @@ -7,11 +7,9 @@ import ( "io" "net/http" "strings" - - "github.com/modelcontextprotocol/registry/tools/publisher/auth" ) -type Provider struct { +type NoneProvider struct { registryURL string token string } @@ -21,13 +19,13 @@ type TokenResponse struct { ExpiresAt int64 `json:"expires_at"` } -func NewProvider(registryURL string) auth.Provider { //nolint:ireturn - return &Provider{ +func NewNoneProvider(registryURL string) Provider { //nolint:ireturn + return &NoneProvider{ registryURL: registryURL, } } -func (p *Provider) GetToken(ctx context.Context) (string, error) { +func (p *NoneProvider) GetToken(ctx context.Context) (string, error) { if p.token != "" { return p.token, nil } @@ -64,15 +62,15 @@ func (p *Provider) GetToken(ctx context.Context) (string, error) { return p.token, nil } -func (p *Provider) NeedsLogin() bool { +func (p *NoneProvider) NeedsLogin() bool { return false } -func (p *Provider) Login(_ context.Context) error { +func (p *NoneProvider) Login(_ context.Context) error { // No login needed for anonymous auth return nil } -func (p *Provider) Name() string { +func (p *NoneProvider) Name() string { return "none" } diff --git a/tools/publisher/main.go b/tools/publisher/main.go index 99ddb09..d7813d7 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -14,8 +14,6 @@ import ( "strings" "github.com/modelcontextprotocol/registry/tools/publisher/auth" - "github.com/modelcontextprotocol/registry/tools/publisher/auth/github" - "github.com/modelcontextprotocol/registry/tools/publisher/auth/none" ) // Server structure types for JSON generation @@ -104,8 +102,7 @@ func publishCommand() error { publishFlags.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") publishFlags.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") publishFlags.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") - publishFlags.StringVar(&authMethod, "auth-method", "github", - "authentication method (github for authenticated, none for anonymous) (default: github)") + publishFlags.StringVar(&authMethod, "auth-method", "github-at", "authentication method (default: github-at)") // Set custom usage function publishFlags.Usage = func() { @@ -117,7 +114,7 @@ func publishCommand() error { fmt.Fprint(os.Stdout, " --registry-url string URL of the registry (required)\n") fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (required)\n") fmt.Fprint(os.Stdout, " --login force a new login even if a token exists\n") - fmt.Fprint(os.Stdout, " --auth-method string authentication method (github for authenticated, none for anonymous) (default: github)\n") + fmt.Fprint(os.Stdout, " --auth-method string authentication method (default: github-at)\n") } if err := publishFlags.Parse(os.Args[2:]); err != nil { @@ -137,12 +134,15 @@ func publishCommand() error { var authProvider auth.Provider // Determine the authentication method switch authMethod { - case "github": - log.Println("Using GitHub OAuth for authentication") - authProvider = github.NewOAuthProvider(forceLogin, registryURL) + case "github-at": + log.Println("Using GitHub Access Token for authentication") + authProvider = auth.NewGitHubATProvider(forceLogin, registryURL) + case "github-oidc": + log.Println("Using GitHub Actions OIDC for authentication") + authProvider = auth.NewGitHubOIDCProvider(registryURL) case "none": log.Println("Using anonymous authentication") - authProvider = none.NewProvider(registryURL) + authProvider = auth.NewNoneProvider(registryURL) default: return fmt.Errorf("unsupported authentication method: %s", authMethod) }