## What this does The publisher CLI now derives the OIDC audience to request from the `--registry` URL instead of using a fixed constant, and the server validates the token's `aud` claim against a new per-deployment env var (`MCP_REGISTRY_GITHUB_OIDC_AUDIENCE`). Production sets this env var to `https://registry.modelcontextprotocol.io` (and per-environment URL elsewhere), so the deployment only accepts tokens that were requested for it. ## Compatibility This is a breaking change for older `mcp-publisher` binaries that still request the legacy hardcoded audience. After this rolls to a deployment, users on old `mcp-publisher` will see a clear `invalid audience: expected …, got […]` error and need to upgrade. Users following the documented `releases/latest` install pattern get the new binary automatically. ## Test plan - [ ] CI green - [ ] Run a synthetic publish from a real GitHub Actions workflow against staging after deploy - [ ] Confirm an older `mcp-publisher` against the same staging deploy returns the expected `invalid audience` error 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
299 lines
8.7 KiB
Go
299 lines
8.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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, pathPrefix string, cfg *config.Config) {
|
|
handler := NewGitHubOIDCHandler(cfg)
|
|
|
|
// GitHub OIDC token exchange endpoint
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "exchange-github-oidc-token" + strings.ReplaceAll(pathPrefix, "/", "-"),
|
|
Method: http.MethodPost,
|
|
Path: pathPrefix + "/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) {
|
|
if h.config.GitHubOIDCAudience == "" {
|
|
return nil, fmt.Errorf("GitHub OIDC exchange disabled: this deployment has no audience configured")
|
|
}
|
|
claims, err := h.validator.ValidateToken(ctx, oidcToken, h.config.GitHubOIDCAudience)
|
|
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: auth.MethodGitHubOIDC,
|
|
AuthMethodSubject: claims.Subject, // e.g. "repo:octo-org/octo-repo:environment:prod"
|
|
Permissions: permissions,
|
|
}
|
|
|
|
// Generate Registry JWT token
|
|
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
|
|
}
|
|
|
|
return tokenResponse, nil
|
|
}
|
|
|
|
func (h *GitHubOIDCHandler) buildPermissions(claims *GitHubOIDCClaims) []auth.Permission {
|
|
permissions := []auth.Permission{}
|
|
|
|
// Validate repository owner name
|
|
if !isValidGitHubName(claims.RepositoryOwner) {
|
|
return nil
|
|
}
|
|
|
|
// Grant publish permissions for the repository owner's namespace
|
|
// We grant io.github.<owner>/* rather than io.github./repo/* because many people have monorepo setups where they want to deploy multiple servers from
|
|
// This also reflects GitHub's permission model, in that GitHub Actions can push to any GitHub package in the repository owner's namespace (e.g. for GHCR)
|
|
permissions = append(permissions, auth.Permission{
|
|
Action: auth.PermissionActionPublish,
|
|
ResourcePattern: fmt.Sprintf("io.github.%s/*", claims.RepositoryOwner),
|
|
})
|
|
|
|
return permissions
|
|
}
|