registry/internal/api/handlers/v0/auth/dns.go
Radoslav Dimitrov c5f50e817b
fix(auth): better DNS verification errors and wrong-selector hint (#1202)
## Summary

DNS auth users have repeatedly hit two failure modes that both produce
the same opaque "Ed25519 signature verification failed" error, making
them indistinguishable to debug. This PR makes both diagnosable
end-to-end without changing the auth protocol or any successful path.

The two recurring failure modes:

1. **Wrong placement** — TXT record put under a selector
(`_mcp-auth.<domain>` or `_mcp-registry.<domain>`) instead of the apex.
DKIM-style intuition, but MCP DNS auth uses SPF-style apex placement.
See #385 (PR #388 fixed the docs but the failure recurred), #1103,
#1126.
2. **Stale apex record** — old TXT record left behind after a key
rotation, silently tried first and rejected.

## Changes

**`internal/api/handlers/v0/auth/dns.go`** — when the apex has no
`v=MCPv1` record, probe `_mcp-auth.<domain>` and
`_mcp-registry.<domain>` in parallel (2s individual timeout). If a
record is found there, return a targeted error pointing the user at the
apex. Costs zero extra DNS work in the success path.

**`internal/api/handlers/v0/auth/common.go`** — when verification fails
against records that *are* present, include short fingerprints of every
key the registry tried (`ed25519:YJLsHFhu`-style 8-char prefix). A new
`ErrSignatureMismatch` sentinel keeps structural errors (wrong signature
size, unsupported algorithm) passing through unchanged via `errors.Is`,
so existing assertions on those messages still hold.

**`docs/modelcontextprotocol-io/authentication.mdx`** — anti-pattern
`<Warning>` callout in the DNS section explicitly warning against
selector placement and stale records.

**Tests** — two new tests:
- `TestDNSAuthHandler_WrongSelectorProbe` — verifies the probe surfaces
records found at `_mcp-auth.` / `_mcp-registry.` and falls through to
the standard error when nothing is found anywhere
- `TestDNSAuthHandler_StaleKeyFingerprintInError` — verifies the
fingerprint hint appears in the failure message for #1126's exact
scenario

One existing test assertion was updated from a generic `"signature
verification failed"` substring to the more specific `"invalid signature
size for ECDSA P-384"` it should always have been asserting; the new
sentinel-based logic surfaces structural errors verbatim.

## Test plan

- [x] `make lint` — clean
- [x] `make test-unit` — all auth tests pass (existing + 2 new)
- [x] Verified existing single-key and multi-key paths preserve their
original (more specific) error messages where they were already specific
- [ ] Reviewer to sanity-check the docs callout renders well in the
Mintlify theme

## Out of scope

The publisher-side preflight DNS check (cross-link in #845) is
intentionally **not** in this PR — that belongs in the broader CLI UX
work tracked by #845.

Refs #845, #385, #1103
Fixes #1126

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:22:06 -04:00

157 lines
5.3 KiB
Go

package auth
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
)
// DNSTokenExchangeInput represents the input for DNS-based authentication
type DNSTokenExchangeInput struct {
Body SignatureTokenExchangeInput
}
// DNSResolver defines the interface for DNS resolution
type DNSResolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
}
// DefaultDNSResolver uses Go's standard DNS resolution
type DefaultDNSResolver struct{}
// LookupTXT performs DNS TXT record lookup
func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return (&net.Resolver{}).LookupTXT(ctx, name)
}
// DNSAuthHandler handles DNS-based authentication
type DNSAuthHandler struct {
CoreAuthHandler
resolver DNSResolver
}
// NewDNSAuthHandler creates a new DNS authentication handler
func NewDNSAuthHandler(cfg *config.Config) *DNSAuthHandler {
return &DNSAuthHandler{
CoreAuthHandler: *NewCoreAuthHandler(cfg),
resolver: &DefaultDNSResolver{},
}
}
// SetResolver sets a custom DNS resolver (used for testing)
func (h *DNSAuthHandler) SetResolver(resolver DNSResolver) {
h.resolver = resolver
}
// RegisterDNSEndpoint registers the DNS authentication endpoint
func RegisterDNSEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
handler := NewDNSAuthHandler(cfg)
// DNS authentication endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-dns-token" + strings.ReplaceAll(pathPrefix, "/", "-"),
Method: http.MethodPost,
Path: pathPrefix + "/auth/dns",
Summary: "Exchange DNS signature for Registry JWT",
Description: "Authenticate using DNS TXT record public key and signed timestamp",
Tags: []string{"auth"},
}, func(ctx context.Context, input *DNSTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
if err != nil {
return nil, huma.Error401Unauthorized("DNS authentication failed", err)
}
return &v0.Response[auth.TokenResponse]{
Body: *response,
}, nil
})
}
// commonWrongSelectors lists subdomain prefixes that users frequently mistake for the
// MCP DNS auth record location (DKIM-style intuition). MCP DNS auth uses the apex,
// like SPF — see #385, #1103, #1126 for the recurring confusion.
var commonWrongSelectors = []string{"_mcp-auth", "_mcp-registry"}
// ExchangeToken exchanges DNS signature for a Registry JWT token
func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) {
keyFetcher := func(ctx context.Context, domain string) ([]string, error) {
// Apply a timeout to DNS lookup to prevent resource exhaustion from slow/malicious DNS servers
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 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
txtRecords, err := h.resolver.LookupTXT(timeoutCtx, domain)
if err != nil {
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
}
if !hasMCPRecord(txtRecords) {
if found := h.findMisplacedSelector(timeoutCtx, domain); found != "" {
return nil, fmt.Errorf(
"no MCPv1 TXT record at %q, but one was found at %q — "+
"MCP DNS auth requires the record at the apex domain, not under a selector",
domain, found,
)
}
}
return txtRecords, nil
}
allowSubdomains := true
return h.CoreAuthHandler.ExchangeToken(ctx, domain, timestamp, signedTimestamp, keyFetcher, allowSubdomains, auth.MethodDNS)
}
// hasMCPRecord reports whether any of the supplied TXT records contains a well-formed
// MCPv1 proof record. Uses the same strict pattern as the parser so a malformed
// "v=MCPv1" string at the apex doesn't suppress the misplaced-selector probe.
func hasMCPRecord(records []string) bool {
for _, r := range records {
if MCPProofRecordPattern.MatchString(r) {
return true
}
}
return false
}
// findMisplacedSelector probes a small fixed set of common wrong selectors and returns the
// first one that holds an MCPv1 record, or "" if none do. Lookups run in parallel with a
// short individual timeout so a slow/missing zone never delays the response by much.
func (h *DNSAuthHandler) findMisplacedSelector(ctx context.Context, domain string) string {
type result struct {
name string
found bool
}
results := make(chan result, len(commonWrongSelectors))
for _, selector := range commonWrongSelectors {
name := selector + "." + domain
go func(name string) {
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
records, err := h.resolver.LookupTXT(lookupCtx, name)
if err != nil {
results <- result{name: name, found: false}
return
}
results <- result{name: name, found: hasMCPRecord(records)}
}(name)
}
for range commonWrongSelectors {
r := <-results
if r.found {
return r.name
}
}
return ""
}