Skip to content

Commit cd66940

Browse files
theJCclaude
andauthored
Add MCP well-known URI auth discovery per MCP Spec (#2527)
* Add MCP well-known URI auth discovery Implements RFC 9728 Protected Resource Metadata discovery via well-known URIs when WWW-Authenticate header is not present. This completes ToolHive's implementation of the MCP specification requirement that clients MUST support both discovery mechanisms. Changes: - Add tryWellKnownDiscovery() to discover auth via well-known URIs - Add buildWellKnownURI() to construct RFC 9728 compliant URIs - Add checkWellKnownURIExists() to validate URI accessibility - Modify DetectAuthenticationFromServer() for well-known fallback - Add comprehensive unit tests for all discovery paths - Add regression tests for empty scope handling contracts The implementation tries endpoint-specific URIs first, then root-level URIs per MCP spec priority order. This enables authentication with MCP-compliant servers that use well-known URIs per RFC 9728 Section 3 but don't send WWW-Authenticate headers. Test coverage: 75.5% for pkg/auth/discovery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jon Christiansen <467023+theJC@users.noreply.github.com> * Update per MR feedback regarding content-type returned from wellknown endpoint, also dont consider wellknown as existing if we cannot access due to unauthorized/401 * fix golangci-lint identified issues * Add more tests for coverage * Update comment to make clearer * PR PR feedback, defensively control how much response draining we are willing to do * Update test to be more effecient per PR feedback --------- Signed-off-by: Jon Christiansen <467023+theJC@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed5428d commit cd66940

File tree

5 files changed

+925
-25
lines changed

5 files changed

+925
-25
lines changed

docs/remote-mcp-authentication.md

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,29 @@ ToolHive is **highly compliant** with the MCP authorization specification, imple
1616
- Extracts `realm` and `resource_metadata` parameters as per RFC 9728
1717
- Handles error and error_description parameters
1818

19-
#### 2. Protected Resource Metadata (RFC 9728)
20-
- **Location**: [`pkg/auth/discovery/discovery.go:531-593`](../pkg/auth/discovery/discovery.go#L531)
21-
- Fetches metadata from `resource_metadata` URL in WWW-Authenticate header
19+
#### 2. Protected Resource Metadata Discovery (RFC 9728 & MCP Specification)
20+
21+
ToolHive implements BOTH discovery mechanisms required by the MCP specification:
22+
23+
**Method 1: WWW-Authenticate Header (Primary)**
24+
- **Location**: [`pkg/auth/discovery/discovery.go:148-156`](../pkg/auth/discovery/discovery.go#L148)
25+
- Extracts `resource_metadata` parameter from `Bearer` scheme in WWW-Authenticate header
26+
- Takes precedence when present (most efficient path)
27+
28+
**Method 2: Well-Known URI Fallback (MCP Specification Requirement)**
29+
- **Location**: [`pkg/auth/discovery/discovery.go:176-254`](../pkg/auth/discovery/discovery.go#L176)
30+
- **Specification**: [MCP Protected Resource Metadata Discovery Requirements](https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements)
31+
- Triggers when no WWW-Authenticate header present
32+
- Tries endpoint-specific URI: `/.well-known/oauth-protected-resource/{path}`
33+
- Falls back to root-level URI: `/.well-known/oauth-protected-resource`
34+
- Uses HTTP GET per RFC 9728 requirement
35+
36+
**Metadata Processing (Common to Both Methods)**
37+
- **Location**: [`pkg/auth/discovery/discovery.go:575-637`](../pkg/auth/discovery/discovery.go#L575)
2238
- Validates HTTPS requirement (with localhost exception for development)
2339
- Verifies required `resource` field presence
2440
- Extracts and processes `authorization_servers` array
41+
- Enables automatic discovery for servers that only implement well-known URIs
2542

2643
#### 3. Authorization Server Discovery (RFC 8414)
2744
- **Location**: [`pkg/auth/discovery/discovery.go:595-621`](../pkg/auth/discovery/discovery.go#L595)
@@ -48,16 +65,29 @@ When ToolHive connects to a remote MCP server ([`pkg/runner/remote_auth.go:27-87
4865

4966
1. Makes test request to the remote server (GET, then optionally POST)
5067
2. Checks for 401 Unauthorized response with WWW-Authenticate header
51-
3. Parses authentication requirements from the header
68+
3. **If WWW-Authenticate header found:** Parses authentication requirements from the header
69+
4. **If no WWW-Authenticate header:** Falls back to RFC 9728 well-known URI discovery:
70+
- Tries `{baseURL}/.well-known/oauth-protected-resource/{path}` (endpoint-specific)
71+
- Falls back to `{baseURL}/.well-known/oauth-protected-resource` (root-level)
5272

5373
### Discovery Priority Chain
5474
ToolHive follows this priority order for discovering the OAuth issuer ([`pkg/runner/remote_auth.go:95-145`](../pkg/runner/remote_auth.go#L95)):
5575

56-
1. **Configured Issuer**: Uses `--remote-auth-issuer` flag if provided
57-
2. **Realm-Derived**: Derives from `realm` parameter in WWW-Authenticate header (RFC 8414)
58-
3. **Resource Metadata**: Fetches from `resource_metadata` URL (RFC 9728)
59-
4. **Well-Known Discovery**: Probes server's well-known endpoints to discover actual issuer (handles issuer mismatch)
60-
5. **URL-Derived**: Falls back to deriving from the remote URL (last resort)
76+
**Phase 1: WWW-Authenticate Header Detection**
77+
1. **Configured Issuer**: Uses `--remote-auth-issuer` flag if provided (highest priority)
78+
2. **WWW-Authenticate Header**: Checks for `Bearer` scheme with:
79+
- **Realm-Derived**: Derives from `realm` parameter (RFC 8414)
80+
- **Resource Metadata**: Fetches from `resource_metadata` URL (RFC 9728)
81+
82+
**Phase 2: Well-Known URI Fallback (MCP Specification Requirement)**
83+
When no WWW-Authenticate header is present, tries RFC 9728 well-known URIs:
84+
3. **Endpoint-Specific Well-Known URI**: `{baseURL}/.well-known/oauth-protected-resource/{path}`
85+
4. **Root-Level Well-Known URI**: `{baseURL}/.well-known/oauth-protected-resource`
86+
5. **Authorization Server Discovery**: Validates each server in metadata via OIDC discovery
87+
6. **Issuer Mismatch Handling**: Accepts authoritative issuer from well-known endpoints per RFC 8414
88+
89+
**Phase 3: Fallback Discovery**
90+
7. **URL-Derived**: Falls back to deriving from the remote URL (last resort)
6191

6292
### Authentication Branches
6393

@@ -66,32 +96,40 @@ graph TD
6696
A[Remote MCP Server Request] --> B{401 Response?}
6797
B -->|No| C[No Authentication Required]
6898
B -->|Yes| D{WWW-Authenticate Header?}
69-
D -->|No| E[No Authentication Required]
7099
D -->|Yes| F{Parse Header}
71-
100+
101+
%% NEW: Well-known URI fallback when no WWW-Authenticate
102+
D -->|No| WK1[Try Well-Known URI Discovery]
103+
WK1 --> WK2{Try Endpoint-Specific URI}
104+
WK2 -->|Found| WK4[Extract Auth Info]
105+
WK2 -->|404| WK3{Try Root-Level URI}
106+
WK3 -->|Found| WK4
107+
WK3 -->|404| E[No Authentication Required]
108+
WK4 --> K[Fetch Resource Metadata]
109+
72110
F --> G{Has Realm URL?}
73111
G -->|Yes| H[Derive Issuer from Realm]
74112
H --> I[OIDC Discovery]
75-
113+
76114
F --> J{Has resource_metadata?}
77-
J -->|Yes| K[Fetch Resource Metadata]
115+
J -->|Yes| K
78116
K --> L[Validate Auth Servers]
79117
L --> M[Use First Valid Server]
80-
118+
81119
F --> S{No Realm/Metadata?}
82120
S -->|Yes| T[Probe Well-Known Endpoints]
83121
T --> U{Found Valid Issuer?}
84122
U -->|Yes| V[Use Discovered Issuer]
85123
U -->|No| W[Derive from URL]
86-
124+
87125
I --> N{Client Credentials?}
88126
M --> N
89127
V --> N
90128
W --> N
91129
N -->|No| O[Dynamic Registration]
92130
N -->|Yes| P[OAuth Flow]
93131
O --> P
94-
132+
95133
P --> Q[Get Access Token]
96134
Q --> R[Authenticated Request]
97135
```
@@ -120,16 +158,66 @@ When `resource_metadata` URL is provided:
120158
- Uses first valid server found
121159
4. **Handle Issuer Mismatch**: Supports cases where metadata URL differs from actual issuer
122160

123-
## Well-Known Endpoint Discovery
161+
## Well-Known URI Discovery (RFC 9728 & MCP Specification)
162+
163+
ToolHive implements the MCP specification's **Protected Resource Metadata Discovery Requirements**, which mandates trying well-known URIs when no WWW-Authenticate header is present.
164+
165+
### Discovery Process
166+
167+
**When to Trigger:**
168+
- Server returns 401 Unauthorized
169+
- No WWW-Authenticate header in response
170+
- No manual `--remote-auth-issuer` configured
124171

125-
When no realm URL or resource metadata is provided ([`pkg/runner/remote_auth.go:175-211`](../pkg/runner/remote_auth.go#L175)):
172+
**Discovery Sequence** ([`pkg/auth/discovery/discovery.go:222-254`](../pkg/auth/discovery/discovery.go#L222)):
126173

127-
1. **Derive Base URL**: Creates a base URL from the server URL
128-
2. **Probe Well-Known Endpoints**: Attempts to fetch OAuth metadata without requiring issuer match
129-
3. **Accept Authoritative Issuer**: Uses the issuer from the well-known response as authoritative per RFC 8414
130-
4. **Log Mismatch**: Records when discovered issuer differs from server URL for debugging
174+
Per MCP spec priority, ToolHive tries well-known URIs in this order:
175+
176+
1. **Endpoint-Specific URI**: `{baseURL}/.well-known/oauth-protected-resource/{original-path}`
177+
- Example: For `https://mcp.example.com/api/v1/mcp`
178+
- Tries: `https://mcp.example.com/.well-known/oauth-protected-resource/api/v1/mcp`
179+
180+
2. **Root-Level URI**: `{baseURL}/.well-known/oauth-protected-resource`
181+
- Example: For `https://mcp.example.com/api/v1/mcp`
182+
- Falls back to: `https://mcp.example.com/.well-known/oauth-protected-resource`
183+
184+
**HTTP Method:**
185+
- Uses `GET` requests per RFC 9728 requirement
186+
- Sets `Accept: application/json` header
187+
- Validates `Content-Type: application/json` header in response
188+
- Returns on first successful response (200 OK only - metadata must be publicly accessible)
189+
190+
**Response Processing:**
191+
- Extracts `authorization_servers` array from metadata
192+
- Validates each authorization server via OIDC discovery
193+
- Uses first valid server found
194+
- Accepts authoritative issuer from well-known response per RFC 8414
195+
196+
**Example: Server with Well-Known URI Only**
197+
198+
Some MCP servers implement RFC 9728 well-known URI but don't send WWW-Authenticate headers:
199+
200+
```bash
201+
# Request to MCP endpoint
202+
GET https://mcp.example.com/api/v1/mcp
203+
→ 401 Unauthorized (no WWW-Authenticate header)
204+
205+
# Well-known URI fallback (root-level)
206+
GET https://mcp.example.com/.well-known/oauth-protected-resource
207+
→ 200 OK
208+
209+
# Response
210+
{
211+
"resource": "https://mcp.example.com",
212+
"authorization_servers": ["https://auth.example.com"],
213+
"bearer_methods_supported": ["header"]
214+
}
215+
216+
# Result
217+
ToolHive automatically discovers and authenticates without manual configuration
218+
```
131219

132-
This approach handles cases where the OAuth provider's issuer differs from the server's public URL, such as when using CDN or worker deployments.
220+
This approach handles cases where servers implement RFC 9728 well-known URI discovery but don't send WWW-Authenticate headers, making authentication completely automatic.
133221

134222
## Dynamic Client Registration Flow
135223

@@ -325,7 +413,8 @@ The `oauth_config` section supports:
325413

326414
| Specification | Status | Implementation |
327415
|--------------|--------|----------------|
328-
| RFC 9728 (Protected Resource Metadata) | ✅ Compliant | Full implementation with validation |
416+
| RFC 9728 (Protected Resource Metadata) | ✅ Fully Compliant | WWW-Authenticate + well-known URI fallback |
417+
| MCP Well-Known URI Fallback | ✅ Compliant | Tries endpoint-specific and root-level URIs per spec |
329418
| RFC 8414 (Authorization Server Metadata) | ✅ Compliant | Accepts authoritative issuer from well-known endpoints |
330419
| RFC 7591 (Dynamic Client Registration) | ✅ Compliant | Automatic registration when needed |
331420
| OAuth 2.1 PKCE | ✅ Compliant | Enabled by default |

pkg/auth/discovery/discovery.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
DefaultAuthDetectTimeout = 10 * time.Second
3535
MaxRetryAttempts = 3
3636
RetryBaseDelay = 2 * time.Second
37+
MaxResponseBodyDrain = 1 * 1024 * 1024 // 1 MB - limit response body draining to prevent resource exhaustion
3738
)
3839

3940
// AuthInfo contains authentication information extracted from WWW-Authenticate header
@@ -112,6 +113,21 @@ func DetectAuthenticationFromServer(ctx context.Context, targetURI string, confi
112113
}
113114
}
114115

116+
// NEW: Well-known URI fallback per MCP specification
117+
// When no WWW-Authenticate header found, try well-known URIs
118+
logger.Debugf("No WWW-Authenticate header found, attempting well-known URI discovery")
119+
120+
wellKnownAuthInfo, err := tryWellKnownDiscovery(detectCtx, client, targetURI)
121+
if err != nil {
122+
logger.Debugf("Well-known URI discovery failed: %v", err)
123+
return nil, nil // Not an error, just no auth detected
124+
}
125+
126+
if wellKnownAuthInfo != nil {
127+
logger.Infof("Discovered authentication via well-known URI")
128+
return wellKnownAuthInfo, nil
129+
}
130+
115131
return nil, nil // No authentication required
116132
}
117133

@@ -156,6 +172,104 @@ func detectAuthWithRequest(
156172
return nil, nil
157173
}
158174

175+
// buildWellKnownURI constructs a well-known URI for OAuth Protected Resource metadata
176+
// per RFC 9728 Section 3.1 and MCP specification
177+
func buildWellKnownURI(parsedURL *url.URL, endpointSpecific bool) string {
178+
baseURL := url.URL{
179+
Scheme: parsedURL.Scheme,
180+
Host: parsedURL.Host,
181+
}
182+
183+
if endpointSpecific && parsedURL.Path != "" && parsedURL.Path != "/" {
184+
// Endpoint-specific: /.well-known/oauth-protected-resource/<original-path>
185+
// Remove leading slash from original path to avoid double slashes
186+
cleanPath := strings.TrimPrefix(parsedURL.Path, "/")
187+
baseURL.Path = path.Join(auth.WellKnownOAuthResourcePath, cleanPath)
188+
} else {
189+
// Root-level: /.well-known/oauth-protected-resource
190+
baseURL.Path = auth.WellKnownOAuthResourcePath
191+
}
192+
193+
return baseURL.String()
194+
}
195+
196+
// checkWellKnownURIExists returns true if a well-known URI is accessible and returns application/json
197+
// Per RFC 9728, protected resource metadata MUST be queried using HTTP GET and MUST return application/json
198+
func checkWellKnownURIExists(ctx context.Context, client *http.Client, uri string) bool {
199+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil)
200+
if err != nil {
201+
logger.Debugf("Failed to create GET request for %s: %v", uri, err)
202+
return false
203+
}
204+
205+
req.Header.Set("Accept", "application/json")
206+
207+
resp, err := client.Do(req)
208+
if err != nil {
209+
logger.Debugf("Failed to check %s: %v", uri, err)
210+
return false
211+
}
212+
defer func() {
213+
// Drain and close response body to enable connection reuse
214+
// Limit draining to MaxResponseBodyDrain to prevent resource exhaustion from large responses
215+
_, _ = io.CopyN(io.Discard, resp.Body, MaxResponseBodyDrain)
216+
_ = resp.Body.Close()
217+
}()
218+
219+
// RFC 9728 requires 200 OK status code - metadata endpoints must be publicly accessible
220+
if resp.StatusCode != http.StatusOK {
221+
return false
222+
}
223+
224+
// RFC 9728 requires Content-Type to be application/json
225+
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
226+
if !strings.Contains(contentType, "application/json") {
227+
logger.Debugf("Well-known URI %s returned unexpected content type: %s", uri, contentType)
228+
return false
229+
}
230+
231+
return true
232+
}
233+
234+
// tryWellKnownDiscovery attempts to discover authentication requirements via well-known URIs
235+
// per MCP specification Section: Protected Resource Metadata Discovery Requirements.
236+
// Tries endpoint-specific path first, then root-level path.
237+
func tryWellKnownDiscovery(ctx context.Context, client *http.Client, targetURI string) (*AuthInfo, error) {
238+
parsedURL, err := url.Parse(targetURI)
239+
if err != nil {
240+
return nil, fmt.Errorf("invalid target URI: %w", err)
241+
}
242+
243+
// Build well-known URIs to try (in priority order per MCP spec)
244+
wellKnownURIs := []string{
245+
// 1. Endpoint-specific: /.well-known/oauth-protected-resource/<path>
246+
buildWellKnownURI(parsedURL, true),
247+
// 2. Root-level: /.well-known/oauth-protected-resource
248+
buildWellKnownURI(parsedURL, false),
249+
}
250+
251+
// Try each well-known URI in order
252+
for _, wellKnownURI := range wellKnownURIs {
253+
logger.Debugf("Trying well-known URI: %s", wellKnownURI)
254+
255+
// Check if the URI exists before attempting to fetch
256+
if !checkWellKnownURIExists(ctx, client, wellKnownURI) {
257+
logger.Debugf("Well-known URI not found: %s", wellKnownURI)
258+
continue
259+
}
260+
261+
// URI exists - return AuthInfo with ResourceMetadata set
262+
// Downstream handler will use FetchResourceMetadata to get the actual metadata
263+
logger.Infof("Found well-known URI: %s", wellKnownURI)
264+
return &AuthInfo{
265+
Type: "OAuth",
266+
ResourceMetadata: wellKnownURI,
267+
}, nil
268+
}
269+
270+
return nil, nil // No well-known metadata found
271+
}
272+
159273
// ParseWWWAuthenticate parses the WWW-Authenticate header to extract authentication information
160274
// Supports multiple authentication schemes and complex header formats
161275
func ParseWWWAuthenticate(header string) (*AuthInfo, error) {

0 commit comments

Comments
 (0)