diff --git a/internal/api/handlers/v0/auth/common.go b/internal/api/handlers/v0/auth/common.go new file mode 100644 index 000000000..e290a234c --- /dev/null +++ b/internal/api/handlers/v0/auth/common.go @@ -0,0 +1,209 @@ +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) +} diff --git a/internal/api/handlers/v0/auth/dns.go b/internal/api/handlers/v0/auth/dns.go index 991f5132e..99c7a3cc2 100644 --- a/internal/api/handlers/v0/auth/dns.go +++ b/internal/api/handlers/v0/auth/dns.go @@ -2,15 +2,9 @@ package auth import ( "context" - "crypto/ed25519" - "encoding/base64" - "encoding/hex" "fmt" "net" "net/http" - "regexp" - "strings" - "time" "github.com/danielgtaylor/huma/v2" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" @@ -20,11 +14,7 @@ import ( // DNSTokenExchangeInput represents the input for DNS-based authentication type DNSTokenExchangeInput struct { - Body 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"` - } + Body SignatureTokenExchangeInput } // DNSResolver defines the interface for DNS resolution @@ -42,17 +32,15 @@ func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]stri // DNSAuthHandler handles DNS-based authentication type DNSAuthHandler struct { - config *config.Config - jwtManager *auth.JWTManager - resolver DNSResolver + CoreAuthHandler + resolver DNSResolver } // NewDNSAuthHandler creates a new DNS authentication handler func NewDNSAuthHandler(cfg *config.Config) *DNSAuthHandler { return &DNSAuthHandler{ - config: cfg, - jwtManager: auth.NewJWTManager(cfg), - resolver: &DefaultDNSResolver{}, + CoreAuthHandler: *NewCoreAuthHandler(cfg), + resolver: &DefaultDNSResolver{}, } } @@ -87,141 +75,18 @@ func RegisterDNSEndpoint(api huma.API, cfg *config.Config) { // ExchangeToken exchanges DNS signature for a Registry JWT token func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) { - // Validate domain format - if !isValidDomain(domain) { - return nil, fmt.Errorf("invalid domain format") - } - - // Parse and validate timestamp - 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 - 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)") - } - - // Decode signature - 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)) - } - - // Lookup DNS TXT records - txtRecords, err := h.resolver.LookupTXT(ctx, domain) - if err != nil { - return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err) - } - - // Parse public keys from TXT records - publicKeys := h.parsePublicKeysFromTXT(txtRecords) - - if len(publicKeys) == 0 { - return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records") - } - - // Verify signature with any of the public keys - messageBytes := []byte(timestamp) - signatureValid := false - for _, publicKey := range publicKeys { - if ed25519.Verify(publicKey, messageBytes, signature) { - signatureValid = true - break - } - } - - if !signatureValid { - return nil, fmt.Errorf("signature verification failed") - } - - // Build permissions for domain and subdomains - permissions := h.buildPermissions(domain) - - // Create JWT claims - jwtClaims := auth.JWTClaims{ - AuthMethod: auth.MethodDNS, - 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 -} - -// parsePublicKeysFromTXT parses Ed25519 public keys from DNS TXT records -func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.PublicKey { - var publicKeys []ed25519.PublicKey - mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`) - - for _, record := range txtRecords { - matches := mcpPattern.FindStringSubmatch(record) - 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 -} - -// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation -func (h *DNSAuthHandler) buildPermissions(domain string) []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), - }, + keyFetcher := func(ctx context.Context, domain string) ([]string, error) { + // Lookup DNS TXT records // DNS implies a hierarchy where subdomains are treated as part of the parent domain, // therefore we grant permissions for all subdomains (e.g., com.example.*) // This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges - { - Action: auth.PermissionActionPublish, - ResourcePattern: fmt.Sprintf("%s.*", reverseDomain), - }, - } - - return permissions -} - -// 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 + txtRecords, err := h.resolver.LookupTXT(ctx, domain) + if err != nil { + return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err) + } + return txtRecords, nil } - // 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) + allowSubdomains := true + return h.CoreAuthHandler.ExchangeToken(ctx, domain, timestamp, signedTimestamp, keyFetcher, allowSubdomains, auth.MethodDNS) } diff --git a/internal/api/handlers/v0/auth/dns_test.go b/internal/api/handlers/v0/auth/dns_test.go index e9c10d52c..994cb9677 100644 --- a/internal/api/handlers/v0/auth/dns_test.go +++ b/internal/api/handlers/v0/auth/dns_test.go @@ -45,7 +45,7 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ - "example.com": { + testDomain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), }, }, @@ -63,7 +63,7 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { }{ { name: "successful authentication", - domain: "example.com", + domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(_ *MockDNSResolver) { // Mock is already set up with valid key @@ -72,14 +72,14 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { }, { name: "multiple keys", - domain: "example.com", + 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["example.com"] = []string{ + 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), @@ -96,14 +96,14 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { }, { name: "timestamp too old", - domain: "example.com", + 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: "example.com", + domain: testDomain, timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339), expectError: true, errorContains: "timestamp outside valid window", @@ -194,3 +194,267 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go index 7a76ec037..d7acebfeb 100644 --- a/internal/api/handlers/v0/auth/http.go +++ b/internal/api/handlers/v0/auth/http.go @@ -2,13 +2,9 @@ package auth import ( "context" - "crypto/ed25519" - "encoding/base64" - "encoding/hex" "fmt" "io" "net/http" - "regexp" "strings" "time" @@ -23,11 +19,7 @@ const MaxKeyResponseSize = 4096 // HTTPTokenExchangeInput represents the input for HTTP-based authentication type HTTPTokenExchangeInput struct { - Body 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"` - } + Body SignatureTokenExchangeInput } // HTTPKeyFetcher defines the interface for fetching HTTP keys @@ -98,17 +90,15 @@ func (f *DefaultHTTPKeyFetcher) FetchKey(ctx context.Context, domain string) (st // HTTPAuthHandler handles HTTP-based authentication type HTTPAuthHandler struct { - config *config.Config - jwtManager *auth.JWTManager - fetcher HTTPKeyFetcher + CoreAuthHandler + fetcher HTTPKeyFetcher } // NewHTTPAuthHandler creates a new HTTP authentication handler func NewHTTPAuthHandler(cfg *config.Config) *HTTPAuthHandler { return &HTTPAuthHandler{ - config: cfg, - jwtManager: auth.NewJWTManager(cfg), - fetcher: NewDefaultHTTPKeyFetcher(), + CoreAuthHandler: *NewCoreAuthHandler(cfg), + fetcher: NewDefaultHTTPKeyFetcher(), } } @@ -143,107 +133,14 @@ func RegisterHTTPEndpoint(api huma.API, cfg *config.Config) { // ExchangeToken exchanges HTTP signature for a Registry JWT token func (h *HTTPAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) { - // Validate domain format - if !isValidDomain(domain) { - return nil, fmt.Errorf("invalid domain format") - } - - // Parse and validate timestamp - 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 - 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)") - } - - // Decode signature - 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)) - } - - // Fetch public key from HTTP endpoint - keyResponse, err := h.fetcher.FetchKey(ctx, domain) - if err != nil { - return nil, fmt.Errorf("failed to fetch public key: %w", err) - } - - // Parse public key from HTTP response - publicKey, err := h.parsePublicKeyFromHTTP(keyResponse) - if err != nil { - return nil, fmt.Errorf("failed to parse public key: %w", err) - } - - // Verify signature - messageBytes := []byte(timestamp) - if !ed25519.Verify(publicKey, messageBytes, signature) { - return nil, fmt.Errorf("signature verification failed") - } - - // Build permissions for domain and subdomains - permissions := h.buildPermissions(domain) - - // Create JWT claims - jwtClaims := auth.JWTClaims{ - AuthMethod: auth.MethodHTTP, - 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 -} - -// parsePublicKeyFromHTTP parses Ed25519 public key from HTTP response -func (h *HTTPAuthHandler) parsePublicKeyFromHTTP(response string) (ed25519.PublicKey, error) { - // Expected format: v=MCPv1; k=ed25519; p= - mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`) - - matches := mcpPattern.FindStringSubmatch(response) - if len(matches) != 2 { - return nil, fmt.Errorf("invalid key format, expected: v=MCPv1; k=ed25519; p=") - } - - // Decode base64 public key - publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1]) - if err != nil { - return nil, fmt.Errorf("failed to decode base64 public key: %w", err) - } - - if len(publicKeyBytes) != ed25519.PublicKeySize { - return nil, fmt.Errorf("invalid public key length: expected %d, got %d", ed25519.PublicKeySize, len(publicKeyBytes)) - } - - return ed25519.PublicKey(publicKeyBytes), nil -} - -// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation -func (h *HTTPAuthHandler) buildPermissions(domain string) []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), - }, - // HTTP does not imply a hierarchy of ownership of subdomains, unlike DNS - // Therefore this does not give permissions for subdomains - // This is consistent with similar protocols, e.g. ACME HTTP-01 + keyFetcher := func(ctx context.Context, domain string) ([]string, error) { + keyResponse, err := h.fetcher.FetchKey(ctx, domain) + if err != nil { + return nil, fmt.Errorf("failed to fetch public key: %w", err) + } + return []string{keyResponse}, nil } - return permissions + allowSubdomains := false + return h.CoreAuthHandler.ExchangeToken(ctx, domain, timestamp, signedTimestamp, keyFetcher, allowSubdomains, auth.MethodHTTP) } diff --git a/internal/api/handlers/v0/auth/http_test.go b/internal/api/handlers/v0/auth/http_test.go index ac32b608d..55a31cda9 100644 --- a/internal/api/handlers/v0/auth/http_test.go +++ b/internal/api/handlers/v0/auth/http_test.go @@ -43,6 +43,8 @@ func newClientForTLSServer(t *testing.T, srv *httptest.Server) *http.Client { return &http.Client{Transport: transport, Timeout: 10 * time.Second} } +const testDomain = "example.com" + // MockHTTPKeyFetcher for testing type MockHTTPKeyFetcher struct { keyResponses map[string]string @@ -70,7 +72,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) mockFetcher := &MockHTTPKeyFetcher{ keyResponses: map[string]string{ - "example.com": fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), + testDomain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), }, } handler.SetFetcher(mockFetcher) @@ -86,7 +88,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { }{ { name: "successful authentication", - domain: "example.com", + domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(_ *MockHTTPKeyFetcher) { // Mock is already set up with valid key @@ -102,28 +104,28 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { }, { name: "invalid timestamp format", - domain: "example.com", + domain: testDomain, timestamp: "invalid-timestamp", expectError: true, errorContains: "invalid timestamp format", }, { name: "timestamp too old", - domain: "example.com", + 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: "example.com", + domain: testDomain, timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339), expectError: true, errorContains: "timestamp outside valid window", }, { name: "invalid signature format", - domain: "example.com", + domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), signedTimestamp: "invalid-hex", expectError: true, @@ -131,7 +133,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { }, { name: "signature wrong length", - domain: "example.com", + domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), signedTimestamp: "abcdef", // too short expectError: true, @@ -156,7 +158,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { m.err = nil }, expectError: true, - errorContains: "invalid key format", + errorContains: "failed to parse public key", }, { name: "invalid base64 key", @@ -167,7 +169,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { m.err = nil }, expectError: true, - errorContains: "failed to decode base64 public key", + errorContains: "failed to parse public key", }, { name: "wrong key size", @@ -180,18 +182,18 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { m.err = nil }, expectError: true, - errorContains: "invalid public key length", + errorContains: "failed to parse public key", }, { name: "signature verification failure", - domain: "example.com", + domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockHTTPKeyFetcher) { // Generate different key pair for signature verification failure wrongPublicKey, _, err := ed25519.GenerateKey(nil) require.NoError(t, err) wrongPublicKeyB64 := base64.StdEncoding.EncodeToString(wrongPublicKey) - m.keyResponses["example.com"] = fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", wrongPublicKeyB64) + m.keyResponses[testDomain] = fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", wrongPublicKeyB64) m.err = nil }, expectError: true, @@ -384,3 +386,356 @@ func TestDefaultHTTPKeyFetcher(t *testing.T) { }) } } + + +func TestHTTPAuthHandler_Permissions(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + handler := auth.NewHTTPAuthHandler(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 only (HTTP does not include subdomains) + }, + unexpectedPatterns: []string{ + "com.example.*", // HTTP should not grant subdomain permissions + "example.com/*", // should be reversed + "*.com.example", // wrong wildcard position + }, + }, + { + name: "subdomain", + domain: "api.example.com", + expectedPatterns: []string{ + "com.example.api/*", // exact subdomain pattern only + }, + unexpectedPatterns: []string{ + "com.example.api.*", // HTTP should not grant subdomain permissions + "com.example/*", // parent domain should not be included + "api.example.com/*", // should be reversed + }, + }, + { + name: "multi-level subdomain", + domain: "v1.api.example.com", + expectedPatterns: []string{ + "com.example.api.v1/*", // exact pattern only + }, + unexpectedPatterns: []string{ + "com.example.api.v1.*", // HTTP should not grant subdomain permissions + "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 only (no reversal needed) + }, + unexpectedPatterns: []string{ + "localhost.*", // HTTP should not grant subdomain permissions + "*.localhost", // wrong wildcard position + }, + }, + { + name: "hyphenated domain", + domain: "my-app.example-site.com", + expectedPatterns: []string{ + "com.example-site.my-app/*", // exact pattern only + }, + unexpectedPatterns: []string{ + "com.example-site.my-app.*", // HTTP should not grant subdomain permissions + "my-app.example-site.com/*", // should be reversed + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up mock fetcher + mockFetcher := &MockHTTPKeyFetcher{ + keyResponses: map[string]string{ + tt.domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), + }, + } + handler.SetFetcher(mockFetcher) + + // 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.MethodHTTP, claims.AuthMethod) + assert.Equal(t, tt.domain, claims.AuthMethodSubject) + assert.Len(t, claims.Permissions, 1) // HTTP only grants exact domain 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 resource scenarios - only exact domain should work for HTTP + 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) + + // Test that subdomain resources are NOT allowed for HTTP + subdomainResource := basePattern + ".subdomain/my-package" + assert.False(t, jwtManager.HasPermission(subdomainResource, intauth.PermissionActionPublish, claims.Permissions), + "Should NOT have permission for subdomain %s with HTTP auth", subdomainResource) + } + } + }) + } +} + +func TestHTTPAuthHandler_PermissionValidation(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + handler := auth.NewHTTPAuthHandler(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 fetcher + mockFetcher := &MockHTTPKeyFetcher{ + keyResponses: map[string]string{ + domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), + }, + } + handler.SetFetcher(mockFetcher) + + // 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 should fail for HTTP", + resource: "com.example.api/my-package", + action: intauth.PermissionActionPublish, + shouldPass: false, // HTTP does not grant subdomain permissions + }, + { + name: "deep subdomain resource should fail for HTTP", + resource: "com.example.v1.api/my-package", + action: intauth.PermissionActionPublish, + shouldPass: false, // HTTP does not grant subdomain permissions + }, + { + 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) + } + }) + } +} + +func TestHTTPvsDNS_PermissionDifferences(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + httpHandler := auth.NewHTTPAuthHandler(cfg) + dnsHandler := 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 mocks + mockFetcher := &MockHTTPKeyFetcher{ + keyResponses: map[string]string{ + domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), + }, + } + httpHandler.SetFetcher(mockFetcher) + + mockResolver := &MockDNSResolver{ + txtRecords: map[string][]string{ + domain: { + fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), + }, + }, + } + dnsHandler.SetResolver(mockResolver) + + // Generate tokens from both handlers + timestamp := time.Now().UTC().Format(time.RFC3339) + signature := ed25519.Sign(privateKey, []byte(timestamp)) + signedTimestamp := hex.EncodeToString(signature) + + httpResult, err := httpHandler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp) + require.NoError(t, err) + + dnsResult, err := dnsHandler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp) + require.NoError(t, err) + + // Validate both tokens + httpClaims, err := jwtManager.ValidateToken(context.Background(), httpResult.RegistryToken) + require.NoError(t, err) + + dnsClaims, err := jwtManager.ValidateToken(context.Background(), dnsResult.RegistryToken) + require.NoError(t, err) + + // Compare permission counts + assert.Len(t, httpClaims.Permissions, 1, "HTTP should grant 1 permission (exact domain only)") + assert.Len(t, dnsClaims.Permissions, 2, "DNS should grant 2 permissions (exact domain + subdomains)") + + // Test resources that should behave differently + testCases := []struct { + name string + resource string + httpAllowed bool + dnsAllowed bool + }{ + { + name: "exact domain resource", + resource: "com.example/my-package", + httpAllowed: true, + dnsAllowed: true, + }, + { + name: "subdomain resource", + resource: "com.example.api/my-package", + httpAllowed: false, // HTTP does not grant subdomain permissions + dnsAllowed: true, // DNS grants subdomain permissions + }, + { + name: "deep subdomain resource", + resource: "com.example.v1.api/my-package", + httpAllowed: false, // HTTP does not grant subdomain permissions + dnsAllowed: true, // DNS grants subdomain permissions + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpPermission := jwtManager.HasPermission(tc.resource, intauth.PermissionActionPublish, httpClaims.Permissions) + dnsPermission := jwtManager.HasPermission(tc.resource, intauth.PermissionActionPublish, dnsClaims.Permissions) + + assert.Equal(t, tc.httpAllowed, httpPermission, "HTTP permission mismatch for %s", tc.resource) + assert.Equal(t, tc.dnsAllowed, dnsPermission, "DNS permission mismatch for %s", tc.resource) + }) + } +} +