registry/internal/api/handlers/v0/auth/dns_test.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

460 lines
14 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"
)
// 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{
testDomain: {
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: testDomain,
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(_ *MockDNSResolver) {
// Mock is already set up with valid key
},
expectError: false,
},
{
name: "multiple keys",
domain: testDomain,
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[testDomain] = []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: testDomain,
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: testDomain,
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, intauth.MethodDNS, 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))
}
})
}
}
func TestDNSAuthHandler_Permissions(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewDNSAuthHandler(cfg)
jwtManager := intauth.NewJWTManager(cfg)
// Generate a test key pair
publicKey, privateKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
tests := []struct {
name string
domain string
expectedPatterns []string
unexpectedPatterns []string
}{
{
name: "simple domain",
domain: testDomain,
expectedPatterns: []string{
"com.example/*", // exact domain pattern
"com.example.*", // subdomain pattern (DNS includes subdomains)
},
unexpectedPatterns: []string{
testDomain + "/*", // should be reversed
"*.com.example", // wrong wildcard position
},
},
{
name: "subdomain",
domain: "api.example.com",
expectedPatterns: []string{
"com.example.api/*", // exact subdomain pattern
"com.example.api.*", // subdomain pattern
},
unexpectedPatterns: []string{
"com.example/*", // parent domain should not be included
"api." + testDomain + "/*", // should be reversed
},
},
{
name: "multi-level subdomain",
domain: "v1.api.example.com",
expectedPatterns: []string{
"com.example.api.v1/*", // exact pattern
"com.example.api.v1.*", // subdomain pattern
},
unexpectedPatterns: []string{
"com.example/*", // parent domain should not be included
"com.example.api/*", // intermediate domain should not be included
"v1.api.example.com/*", // should be reversed
},
},
{
name: "single part domain",
domain: "localhost",
expectedPatterns: []string{
"localhost/*", // exact pattern (no reversal needed)
"localhost.*", // subdomain pattern
},
unexpectedPatterns: []string{
"*.localhost", // wrong wildcard position
},
},
{
name: "hyphenated domain",
domain: "my-app.example-site.com",
expectedPatterns: []string{
"com.example-site.my-app/*", // exact pattern
"com.example-site.my-app.*", // subdomain pattern
},
unexpectedPatterns: []string{
"my-app.example-site.com/*", // should be reversed
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up mock resolver
mockResolver := &MockDNSResolver{
txtRecords: map[string][]string{
tt.domain: {
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
},
},
}
handler.SetResolver(mockResolver)
// Generate signature
timestamp := time.Now().UTC().Format(time.RFC3339)
signature := ed25519.Sign(privateKey, []byte(timestamp))
signedTimestamp := hex.EncodeToString(signature)
// Exchange token
result, err := handler.ExchangeToken(context.Background(), tt.domain, timestamp, signedTimestamp)
require.NoError(t, err)
require.NotNil(t, result)
// Validate JWT token
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
require.NoError(t, err)
// Verify claims structure
assert.Equal(t, intauth.MethodDNS, claims.AuthMethod)
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 2) // DNS always grants both exact and subdomain permissions
// Extract permission patterns
patterns := make([]string, len(claims.Permissions))
for i, perm := range claims.Permissions {
patterns[i] = perm.ResourcePattern
// All permissions should be for publish action
assert.Equal(t, intauth.PermissionActionPublish, perm.Action)
}
// Check expected patterns are present
for _, expectedPattern := range tt.expectedPatterns {
assert.Contains(t, patterns, expectedPattern, "Expected pattern %s not found", expectedPattern)
}
// Check unexpected patterns are not present
for _, unexpectedPattern := range tt.unexpectedPatterns {
assert.NotContains(t, patterns, unexpectedPattern, "Unexpected pattern %s found", unexpectedPattern)
}
// Verify the permission patterns work correctly with the JWT manager's HasPermission method
for _, expectedPattern := range tt.expectedPatterns {
// Find the permission with this pattern
var foundPerm *intauth.Permission
for _, perm := range claims.Permissions {
if perm.ResourcePattern == expectedPattern {
foundPerm = &perm
break
}
}
require.NotNil(t, foundPerm, "Permission with pattern %s not found", expectedPattern)
// Test various resource scenarios
if strings.HasSuffix(expectedPattern, "/*") {
// Exact domain permissions (e.g., "com.example/*")
basePattern := strings.TrimSuffix(expectedPattern, "/*")
testResource := basePattern + "/my-package"
assert.True(t, jwtManager.HasPermission(testResource, intauth.PermissionActionPublish, claims.Permissions),
"Should have permission for %s with pattern %s", testResource, expectedPattern)
} else if strings.HasSuffix(expectedPattern, ".*") {
// Subdomain permissions (e.g., "com.example.*")
basePattern := strings.TrimSuffix(expectedPattern, ".*")
testResource := basePattern + ".subdomain/my-package"
assert.True(t, jwtManager.HasPermission(testResource, intauth.PermissionActionPublish, claims.Permissions),
"Should have permission for %s with pattern %s", testResource, expectedPattern)
}
}
})
}
}
func TestDNSAuthHandler_PermissionValidation(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewDNSAuthHandler(cfg)
jwtManager := intauth.NewJWTManager(cfg)
// Generate a test key pair
publicKey, privateKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
domain := testDomain
// Set up mock resolver
mockResolver := &MockDNSResolver{
txtRecords: map[string][]string{
domain: {
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
},
},
}
handler.SetResolver(mockResolver)
// Generate signature and exchange token
timestamp := time.Now().UTC().Format(time.RFC3339)
signature := ed25519.Sign(privateKey, []byte(timestamp))
signedTimestamp := hex.EncodeToString(signature)
result, err := handler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp)
require.NoError(t, err)
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
require.NoError(t, err)
// Test permission validation scenarios
testCases := []struct {
name string
resource string
action intauth.PermissionAction
shouldPass bool
}{
{
name: "exact domain resource with publish action",
resource: "com.example/my-package",
action: intauth.PermissionActionPublish,
shouldPass: true,
},
{
name: "subdomain resource with publish action",
resource: "com.example.api/my-package",
action: intauth.PermissionActionPublish,
shouldPass: true,
},
{
name: "deep subdomain resource with publish action",
resource: "com.example.v1.api/my-package",
action: intauth.PermissionActionPublish,
shouldPass: true,
},
{
name: "different domain should fail",
resource: "com.otherdomain/my-package",
action: intauth.PermissionActionPublish,
shouldPass: false,
},
{
name: "partial domain match should fail",
resource: "com.example-other/my-package",
action: intauth.PermissionActionPublish,
shouldPass: false,
},
{
name: "parent domain should fail",
resource: "com/my-package",
action: intauth.PermissionActionPublish,
shouldPass: false,
},
{
name: "edit action should fail (not granted)",
resource: "com.example/my-package",
action: intauth.PermissionActionEdit,
shouldPass: false,
},
{
name: "resource without package separator should fail",
resource: "com.example",
action: intauth.PermissionActionPublish,
shouldPass: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, claims.Permissions)
if tc.shouldPass {
assert.True(t, hasPermission, "Expected permission for resource %s with action %s", tc.resource, tc.action)
} else {
assert.False(t, hasPermission, "Expected no permission for resource %s with action %s", tc.resource, tc.action)
}
})
}
}