registry/internal/api/handlers/v0/auth/common.go
Joel Verhagen df018fc43b
Share more code between DNS and HTTP auth flows (#552)
<!-- Provide a brief summary of your changes -->

## Motivation and Context

Currently the `dns` and `http` based auth flows share code well in the
publisher tool. However there is a lot of duplication in the auth
endpoint. This on it's own is not a problem but it does make
enhancements to the proof record (the `v=MCPv1; ...` string) harder to
keep in sync.

This PR adds a `common.go` file to share logic between the two.

This supports work I am prototyping for
https://github.com/modelcontextprotocol/registry/issues/482.

## How Has This Been Tested?

Added new test cases for the permissions. Ran them before and after the
refactoring to ensure no behavior change.
Ran existing UTs.
Ran the integration tests.
Manually exercised the flows locally.

## Breaking Changes

None.

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
2025-09-29 23:43:27 +01:00

209 lines
6.3 KiB
Go

package auth
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
)
// SignatureTokenExchangeInput represents the common input structure for token exchange
type SignatureTokenExchangeInput struct {
Domain string `json:"domain" doc:"Domain name" example:"example.com" required:"true"`
Timestamp string `json:"timestamp" doc:"RFC3339 timestamp" example:"2023-01-01T00:00:00Z" required:"true"`
SignedTimestamp string `json:"signed_timestamp" doc:"Hex-encoded Ed25519 signature of timestamp" example:"abcdef1234567890" required:"true"`
}
// KeyFetcher defines a function type for fetching keys from external sources
type KeyFetcher func(ctx context.Context, domain string) ([]string, error)
// CoreAuthHandler represents the common handler structure
type CoreAuthHandler struct {
config *config.Config
jwtManager *auth.JWTManager
}
// NewCoreAuthHandler creates a new core authentication handler
func NewCoreAuthHandler(cfg *config.Config) *CoreAuthHandler {
return &CoreAuthHandler{
config: cfg,
jwtManager: auth.NewJWTManager(cfg),
}
}
// ValidateDomainAndTimestamp validates the domain format and timestamp
func ValidateDomainAndTimestamp(domain, timestamp string) (*time.Time, error) {
if !IsValidDomain(domain) {
return nil, fmt.Errorf("invalid domain format")
}
ts, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return nil, fmt.Errorf("invalid timestamp format: %w", err)
}
// Check timestamp is within 15 seconds, to allow for clock skew
now := time.Now()
if ts.Before(now.Add(-15*time.Second)) || ts.After(now.Add(15*time.Second)) {
return nil, fmt.Errorf("timestamp outside valid window (±15 seconds)")
}
return &ts, nil
}
func DecodeAndValidateSignature(signedTimestamp string) ([]byte, error) {
signature, err := hex.DecodeString(signedTimestamp)
if err != nil {
return nil, fmt.Errorf("invalid signature format, must be hex: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return nil, fmt.Errorf("invalid signature length: expected %d, got %d", ed25519.SignatureSize, len(signature))
}
return signature, nil
}
func VerifySignatureWithKeys(publicKeys []ed25519.PublicKey, messageBytes []byte, signature []byte) bool {
for _, publicKey := range publicKeys {
if ed25519.Verify(publicKey, messageBytes, signature) {
return true
}
}
return false
}
// BuildPermissions builds permissions for a domain with optional subdomain support
func BuildPermissions(domain string, includeSubdomains bool) []auth.Permission {
reverseDomain := ReverseString(domain)
permissions := []auth.Permission{
// Grant permissions for the exact domain (e.g., com.example/*)
{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
},
}
if includeSubdomains {
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
})
}
return permissions
}
// CreateJWTClaimsAndToken creates JWT claims and generates a token response
func (h *CoreAuthHandler) CreateJWTClaimsAndToken(ctx context.Context, authMethod auth.Method, domain string, permissions []auth.Permission) (*auth.TokenResponse, error) {
// Create JWT claims
jwtClaims := auth.JWTClaims{
AuthMethod: authMethod,
AuthMethodSubject: domain,
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
}
// ExchangeToken is a shared method for token exchange that takes a key fetcher function,
// subdomain inclusion flag, and auth method
func (h *CoreAuthHandler) ExchangeToken(
ctx context.Context,
domain, timestamp, signedTimestamp string,
keyFetcher KeyFetcher,
includeSubdomains bool,
authMethod auth.Method) (*auth.TokenResponse, error) {
_, err := ValidateDomainAndTimestamp(domain, timestamp)
if err != nil {
return nil, err
}
signature, err := DecodeAndValidateSignature(signedTimestamp)
if err != nil {
return nil, err
}
keyStrings, err := keyFetcher(ctx, domain)
if err != nil {
return nil, fmt.Errorf("failed to fetch keys: %w", err)
}
publicKeys := ParseMCPKeysFromStrings(keyStrings)
if len(publicKeys) == 0 {
switch authMethod {
case auth.MethodHTTP:
return nil, fmt.Errorf("failed to parse public key")
case auth.MethodDNS:
return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records")
case auth.MethodGitHubAT, auth.MethodGitHubOIDC, auth.MethodOIDC, auth.MethodNone:
return nil, fmt.Errorf("no valid MCP public keys found using %s authentication", authMethod)
}
}
messageBytes := []byte(timestamp)
if !VerifySignatureWithKeys(publicKeys, messageBytes, signature) {
return nil, fmt.Errorf("signature verification failed")
}
permissions := BuildPermissions(domain, includeSubdomains)
return h.CreateJWTClaimsAndToken(ctx, authMethod, domain, permissions)
}
func ParseMCPKeysFromStrings(inputs []string) []ed25519.PublicKey {
var publicKeys []ed25519.PublicKey
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
for _, input := range inputs {
matches := mcpPattern.FindStringSubmatch(input)
if len(matches) == 2 {
// Decode base64 public key
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
if err != nil {
continue // Skip invalid keys
}
if len(publicKeyBytes) != ed25519.PublicKeySize {
continue // Skip invalid key sizes
}
publicKeys = append(publicKeys, ed25519.PublicKey(publicKeyBytes))
}
}
return publicKeys
}
// ReverseString reverses a domain string (example.com -> com.example)
func ReverseString(domain string) string {
parts := strings.Split(domain, ".")
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
return strings.Join(parts, ".")
}
func IsValidDomain(domain string) bool {
if len(domain) == 0 || len(domain) > 253 {
return false
}
// Check for valid characters and structure
domainPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`)
return domainPattern.MatchString(domain)
}