Skip to content

Commit 0ba253d

Browse files
committed
feat: Add HTTP-based authentication support to validate domain ownership
1 parent 875d541 commit 0ba253d

File tree

9 files changed

+670
-120
lines changed

9 files changed

+670
-120
lines changed

internal/api/handlers/v0/auth/http.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/ed25519"
6+
"encoding/base64"
7+
"encoding/hex"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"regexp"
12+
"strings"
13+
"time"
14+
15+
"github.com/danielgtaylor/huma/v2"
16+
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
17+
"github.com/modelcontextprotocol/registry/internal/auth"
18+
"github.com/modelcontextprotocol/registry/internal/config"
19+
"github.com/modelcontextprotocol/registry/internal/model"
20+
)
21+
22+
// HTTPTokenExchangeInput represents the input for HTTP-based authentication
23+
type HTTPTokenExchangeInput struct {
24+
Body struct {
25+
Domain string `json:"domain" doc:"Domain name" example:"example.com" required:"true"`
26+
Timestamp string `json:"timestamp" doc:"RFC3339 timestamp" example:"2023-01-01T00:00:00Z" required:"true"`
27+
SignedTimestamp string `json:"signed_timestamp" doc:"Hex-encoded Ed25519 signature of timestamp" example:"abcdef1234567890" required:"true"`
28+
}
29+
}
30+
31+
// HTTPKeyFetcher defines the interface for fetching HTTP keys
32+
type HTTPKeyFetcher interface {
33+
FetchKey(ctx context.Context, domain string) (string, error)
34+
}
35+
36+
// DefaultHTTPKeyFetcher uses Go's standard HTTP client
37+
type DefaultHTTPKeyFetcher struct {
38+
client *http.Client
39+
}
40+
41+
// NewDefaultHTTPKeyFetcher creates a new HTTP key fetcher with timeout
42+
func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher {
43+
return &DefaultHTTPKeyFetcher{
44+
client: &http.Client{
45+
Timeout: 10 * time.Second,
46+
},
47+
}
48+
}
49+
50+
// FetchKey fetches the public key from the well-known HTTP endpoint
51+
func (f *DefaultHTTPKeyFetcher) FetchKey(ctx context.Context, domain string) (string, error) {
52+
url := fmt.Sprintf("https://%s/.well-known/mcp-registry-auth", domain)
53+
54+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
55+
if err != nil {
56+
return "", fmt.Errorf("failed to create request: %w", err)
57+
}
58+
59+
req.Header.Set("Accept", "text/plain")
60+
req.Header.Set("User-Agent", "mcp-registry/1.0")
61+
62+
resp, err := f.client.Do(req)
63+
if err != nil {
64+
return "", fmt.Errorf("failed to fetch key: %w", err)
65+
}
66+
defer resp.Body.Close()
67+
68+
if resp.StatusCode != http.StatusOK {
69+
return "", fmt.Errorf("HTTP %d: failed to fetch key from %s", resp.StatusCode, url)
70+
}
71+
72+
body, err := io.ReadAll(resp.Body)
73+
if err != nil {
74+
return "", fmt.Errorf("failed to read response body: %w", err)
75+
}
76+
77+
return strings.TrimSpace(string(body)), nil
78+
}
79+
80+
// HTTPAuthHandler handles HTTP-based authentication
81+
type HTTPAuthHandler struct {
82+
config *config.Config
83+
jwtManager *auth.JWTManager
84+
fetcher HTTPKeyFetcher
85+
}
86+
87+
// NewHTTPAuthHandler creates a new HTTP authentication handler
88+
func NewHTTPAuthHandler(cfg *config.Config) *HTTPAuthHandler {
89+
return &HTTPAuthHandler{
90+
config: cfg,
91+
jwtManager: auth.NewJWTManager(cfg),
92+
fetcher: NewDefaultHTTPKeyFetcher(),
93+
}
94+
}
95+
96+
// SetFetcher sets a custom HTTP key fetcher (used for testing)
97+
func (h *HTTPAuthHandler) SetFetcher(fetcher HTTPKeyFetcher) {
98+
h.fetcher = fetcher
99+
}
100+
101+
// RegisterHTTPEndpoint registers the HTTP authentication endpoint
102+
func RegisterHTTPEndpoint(api huma.API, cfg *config.Config) {
103+
handler := NewHTTPAuthHandler(cfg)
104+
105+
// HTTP authentication endpoint
106+
huma.Register(api, huma.Operation{
107+
OperationID: "exchange-http-token",
108+
Method: http.MethodPost,
109+
Path: "/v0/auth/http",
110+
Summary: "Exchange HTTP signature for Registry JWT",
111+
Description: "Authenticate using HTTP-hosted public key and signed timestamp",
112+
Tags: []string{"auth"},
113+
}, func(ctx context.Context, input *HTTPTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
114+
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
115+
if err != nil {
116+
return nil, huma.Error401Unauthorized("HTTP authentication failed", err)
117+
}
118+
119+
return &v0.Response[auth.TokenResponse]{
120+
Body: *response,
121+
}, nil
122+
})
123+
}
124+
125+
// ExchangeToken exchanges HTTP signature for a Registry JWT token
126+
func (h *HTTPAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) {
127+
// Validate domain format
128+
if !isValidDomain(domain) {
129+
return nil, fmt.Errorf("invalid domain format")
130+
}
131+
132+
// Parse and validate timestamp
133+
ts, err := time.Parse(time.RFC3339, timestamp)
134+
if err != nil {
135+
return nil, fmt.Errorf("invalid timestamp format: %w", err)
136+
}
137+
138+
// Check timestamp is within 15 seconds
139+
now := time.Now()
140+
if ts.Before(now.Add(-15*time.Second)) || ts.After(now.Add(15*time.Second)) {
141+
return nil, fmt.Errorf("timestamp outside valid window (±15 seconds)")
142+
}
143+
144+
// Decode signature
145+
signature, err := hex.DecodeString(signedTimestamp)
146+
if err != nil {
147+
return nil, fmt.Errorf("invalid signature format, must be hex: %w", err)
148+
}
149+
150+
if len(signature) != ed25519.SignatureSize {
151+
return nil, fmt.Errorf("invalid signature length: expected %d, got %d", ed25519.SignatureSize, len(signature))
152+
}
153+
154+
// Fetch public key from HTTP endpoint
155+
keyResponse, err := h.fetcher.FetchKey(ctx, domain)
156+
if err != nil {
157+
return nil, fmt.Errorf("failed to fetch public key: %w", err)
158+
}
159+
160+
// Parse public key from HTTP response
161+
publicKey, err := h.parsePublicKeyFromHTTP(keyResponse)
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to parse public key: %w", err)
164+
}
165+
166+
// Verify signature
167+
messageBytes := []byte(timestamp)
168+
if !ed25519.Verify(publicKey, messageBytes, signature) {
169+
return nil, fmt.Errorf("signature verification failed")
170+
}
171+
172+
// Build permissions for domain and subdomains
173+
permissions := h.buildPermissions(domain)
174+
175+
// Create JWT claims
176+
jwtClaims := auth.JWTClaims{
177+
AuthMethod: model.AuthMethodHTTP,
178+
AuthMethodSubject: domain,
179+
Permissions: permissions,
180+
}
181+
182+
// Generate Registry JWT token
183+
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
184+
if err != nil {
185+
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
186+
}
187+
188+
return tokenResponse, nil
189+
}
190+
191+
// parsePublicKeyFromHTTP parses Ed25519 public key from HTTP response
192+
func (h *HTTPAuthHandler) parsePublicKeyFromHTTP(response string) (ed25519.PublicKey, error) {
193+
// Expected format: v=MCPv1; k=ed25519; p=<base64-encoded-key>
194+
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
195+
196+
matches := mcpPattern.FindStringSubmatch(response)
197+
if len(matches) != 2 {
198+
return nil, fmt.Errorf("invalid key format, expected: v=MCPv1; k=ed25519; p=<base64-key>")
199+
}
200+
201+
// Decode base64 public key
202+
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
203+
if err != nil {
204+
return nil, fmt.Errorf("failed to decode base64 public key: %w", err)
205+
}
206+
207+
if len(publicKeyBytes) != ed25519.PublicKeySize {
208+
return nil, fmt.Errorf("invalid public key length: expected %d, got %d", ed25519.PublicKeySize, len(publicKeyBytes))
209+
}
210+
211+
return ed25519.PublicKey(publicKeyBytes), nil
212+
}
213+
214+
// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation
215+
func (h *HTTPAuthHandler) buildPermissions(domain string) []auth.Permission {
216+
reverseDomain := reverseString(domain)
217+
218+
permissions := []auth.Permission{
219+
// Grant permissions for the exact domain (e.g., com.example/*)
220+
{
221+
Action: auth.PermissionActionPublish,
222+
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
223+
},
224+
// HTTP does not imply a hierarchy of ownership of subdomains, unlike DNS
225+
// Therefore this does not give permissions for subdomains
226+
// This is consistent with similar protocols, e.g. ACME HTTP-01
227+
}
228+
229+
return permissions
230+
}

0 commit comments

Comments
 (0)