registry/internal/api/handlers/v0/auth/dns_test.go
adam jones 316cbb08ee
auth: Add DNS auth (#279)
Fixes #270
Closes #233

Adds support for DNS-based authentication for proving ownership of a
particular domain. E.g. if you can add a TXT record to `anthropic.com`,
you can publish anything at `com.anthropic/*` or `com.anthropic.*`.

Notes:
- Uses Ed25519 signatures with 15-second timestamp windows to prevent
replay attacks
- Supports multiple public keys in DNS TXT records for key rotation
scenarios
- Publisher CLI supports DNS auth via --dns-domain and --dns-private-key
flags
- Includes key generation commands in publisher README for easy setup

## Motivation and Context

This implements DNS-based public/private key authentication to allow
people to actually publish at their own domains. Currently,
authentication requires GitHub accounts which limits publishing to the
io.github.* namespace.

DNS-based auth enables:
- Publishing under your actual domain (e.g., com.anthropic/* for
anthropic.com)
- Independence from GitHub or other external auth providers
- Organizations managing their own keys and access to publishing

## How Has This Been Tested?

- Unit tests cover signature verification, DNS TXT parsing, timestamp
validation, and error cases
- Tested locally by editing my own DNS records
- All existing auth methods covered by passing tests

## Breaking Changes

None

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

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

## Additional context

Will be adding HTTP challenge auth in a similar way as a separate PR,
e.g. for verifying you own `github.com` by hosting a
`github.com/.well-known/mcp-server-challenge` or similar. Just
mentioning this as we discussed HTTP challenges as closely related to
DNS challenges elsewhere, and want to reassure reviewer that this hasn't
been forgotten with this approach. :) Update: tracking the HTTP auth in
https://github.com/modelcontextprotocol/registry/issues/280
2025-08-21 22:43:20 +01:00

197 lines
5.8 KiB
Go

package auth_test
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
intauth "github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
)
// MockDNSResolver for testing
type MockDNSResolver struct {
txtRecords map[string][]string
err error
}
func (m *MockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
if m.err != nil {
return nil, m.err
}
return m.txtRecords[name], nil
}
func TestDNSAuthHandler_ExchangeToken(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewDNSAuthHandler(cfg)
// Generate a test key pair
publicKey, privateKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
// Create mock DNS resolver
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
mockResolver := &MockDNSResolver{
txtRecords: map[string][]string{
"example.com": {
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
},
},
}
handler.SetResolver(mockResolver)
tests := []struct {
name string
domain string
timestamp string
signedTimestamp string
setupMock func(*MockDNSResolver)
expectError bool
errorContains string
}{
{
name: "successful authentication",
domain: "example.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(_ *MockDNSResolver) {
// Mock is already set up with valid key
},
expectError: false,
},
{
name: "multiple keys",
domain: "example.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockDNSResolver) {
publicKey, _, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
otherPublicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
m.txtRecords["example.com"] = []string{
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", "someNonsense"),
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", otherPublicKeyB64),
}
},
expectError: false,
},
{
name: "invalid domain format",
domain: "invalid..domain",
timestamp: time.Now().UTC().Format(time.RFC3339),
expectError: true,
errorContains: "invalid domain format",
},
{
name: "timestamp too old",
domain: "example.com",
timestamp: time.Now().Add(-30 * time.Second).UTC().Format(time.RFC3339),
expectError: true,
errorContains: "timestamp outside valid window",
},
{
name: "timestamp too far in the future",
domain: "example.com",
timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339),
expectError: true,
errorContains: "timestamp outside valid window",
},
{
name: "DNS lookup failure",
domain: "nonexistent.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockDNSResolver) {
m.err = fmt.Errorf("DNS lookup failed")
},
expectError: true,
errorContains: "failed to lookup DNS TXT records",
},
{
name: "no MCP TXT records",
domain: "nokeys.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockDNSResolver) {
m.txtRecords["nokeys.com"] = []string{"v=spf1 include:_spf.google.com ~all"}
m.err = nil
},
expectError: true,
errorContains: "no valid MCP public keys found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset mock resolver
mockResolver.err = nil
if tt.setupMock != nil {
tt.setupMock(mockResolver)
}
// Generate signature if not provided
signedTimestamp := tt.signedTimestamp
if signedTimestamp == "" && !tt.expectError {
signature := ed25519.Sign(privateKey, []byte(tt.timestamp))
signedTimestamp = hex.EncodeToString(signature)
} else if signedTimestamp == "" {
// For error cases, generate a valid signature unless we're testing signature format
if !strings.Contains(tt.errorContains, "signature") {
signature := ed25519.Sign(privateKey, []byte(tt.timestamp))
signedTimestamp = hex.EncodeToString(signature)
} else {
signedTimestamp = "invalid"
}
}
// Call the handler
result, err := handler.ExchangeToken(context.Background(), tt.domain, tt.timestamp, signedTimestamp)
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.RegistryToken)
// Verify the token contains expected claims
jwtManager := intauth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
require.NoError(t, err)
assert.Equal(t, model.AuthMethodDNS, claims.AuthMethod)
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 2) // domain and subdomain permissions
// Check permissions use reverse DNS patterns
patterns := make([]string, len(claims.Permissions))
for i, perm := range claims.Permissions {
patterns[i] = perm.ResourcePattern
}
// Convert domain to reverse DNS for expected patterns
parts := strings.Split(tt.domain, ".")
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
reverseDomain := strings.Join(parts, ".")
assert.Contains(t, patterns, fmt.Sprintf("%s/*", reverseDomain))
assert.Contains(t, patterns, fmt.Sprintf("%s.*", reverseDomain))
}
})
}
}