Skip to content

Commit ee910ca

Browse files
authored
auth: Add HTTP-based authentication support to validate domain ownership (#282)
Add HTTP-based authentication to prove domain ownership, similar to #238 DNS authentication but using HTTPS well-known URIs instead of DNS TXT records. Fixes #280 ## Motivation and Context Some organizations don't have easy access to DNS records, but can easily deploy web assets. They would therefore prefer to be able to complete a HTTP challenge rather than a DNS one to authenticate to the registry. This would in some way mirror ACME challenges: where you can do a DNS-01 or a HTTP-01 challenge. ## How Has This Been Tested? - Tested locally by stubbing out the HTTP getter - Added unit 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 I concern I had when building this that it might expose us to SSRF attacks. I think on reflection this is not a significant problem: - We only GET a fixed file `.well-known/mcp-registry-auth` (redirects disallowed) after sanitizing the domain, and then also don't expose the response to the user. This means the user shouldn't be able to hit anything very interesting. - The information they might get is what domains/servers exist in the network. Our infrastructure is already all open-source, so attackers aren't getting any additional real info by prodding at the environment this way.
1 parent 316cbb0 commit ee910ca

File tree

9 files changed

+678
-118
lines changed

9 files changed

+678
-118
lines changed

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

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

0 commit comments

Comments
 (0)