Skip to content

Commit c15705d

Browse files
saucowclaude
andauthored
bugfix: OAuth Discovery Robustness, Logging, and Test Coverage (#1)
* Add robustness improvements for OAuth discovery Make WWW-Authenticate header optional with fallback to well-known endpoints. This enables discovery to work with partially-compliant MCP servers that: - Don't provide WWW-Authenticate headers - Provide unparseable WWW-Authenticate headers - Return non-401 responses that still require OAuth Changes: - Make WWW-Authenticate optional: log warning if missing/unparseable instead of failing - Add nil check before calling FindResourceMetadataURL(challenges) - Continue discovery even if initial response isn't 401 (log warning) - Fallback to /.well-known/oauth-protected-resource when resource_metadata URL not available This aligns with RFC 9728 requirement that servers MUST provide the well-known endpoint, making it a valid fallback when WWW-Authenticate is unavailable. Fixes issue with Neon server and other servers that don't fully implement MCP Authorization Specification Section 4.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add structured logging via context pattern Replace fmt.Printf with proper structured logging that integrates with caller's logging infrastructure. Changes: - Add Logger interface with Infof/Warnf/Errorf methods - Support context-based logger injection via WithLogger/LoggerFromContext - Provide WrapLogger helper to adapt any compatible logger - Update DiscoverOAuthRequirements to use logger from context - Fallback to default stderr logger if no logger provided Benefits: - Logs from library now appear with proper component tags ([com.docker.backend.dcr]) - No messy grep patterns needed - logs integrate naturally - Pluggable logging - works with any logger (logrus, zap, slog, etc.) - Backward compatible - uses default logger if none provided This is the Go-idiomatic approach for library logging, following the context pattern used throughout the Go ecosystem. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add ci + tests * Add template, fix logs * fix comment + redirect URL --------- Co-authored-by: Claude <[email protected]>
1 parent 5b952a1 commit c15705d

File tree

11 files changed

+625
-39
lines changed

11 files changed

+625
-39
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
**What I did**
2+
3+
**Related issue**
4+
<!-- If this is a bug fix, make sure your description includes "fixes #xxxx", or "closes #xxxx" -->
5+
6+
**(not mandatory) A picture of a cute animal, if possible in relation to what you did**
File renamed without changes.

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
pull_request:
8+
push:
9+
branches:
10+
- main
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
18+
19+
- name: Set up Docker Buildx
20+
uses: docker/setup-buildx-action@v3
21+
22+
- name: Run tests
23+
run: make test
24+
25+
lint:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout code
29+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
30+
31+
- name: Set up Docker Buildx
32+
uses: docker/setup-buildx-action@v3
33+
34+
- name: Run linter for Linux
35+
run: make lint-linux

dcr.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"net/http"
1010
)
1111

12+
const DefaultRedirectURI = "https://mcp.docker.com/oauth/callback"
13+
1214
// PerformDCR performs Dynamic Client Registration with the authorization server
1315
// Returns client credentials for the registered public client
1416
//
@@ -23,10 +25,8 @@ func PerformDCR(ctx context.Context, discovery *Discovery, serverName string) (*
2325

2426
// Build DCR request for PUBLIC client
2527
registration := DCRRequest{
26-
ClientName: fmt.Sprintf("MCP Gateway - %s", serverName),
27-
RedirectURIs: []string{
28-
"https://mcp.docker.com/oauth/callback", // mcp-oauth proxy callback only
29-
},
28+
ClientName: fmt.Sprintf("MCP Gateway - %s", serverName),
29+
RedirectURIs: []string{DefaultRedirectURI},
3030
TokenEndpointAuthMethod: "none", // PUBLIC client (no client secret)
3131
GrantTypes: []string{"authorization_code", "refresh_token"},
3232
ResponseTypes: []string{"code"},
@@ -41,7 +41,6 @@ func PerformDCR(ctx context.Context, discovery *Discovery, serverName string) (*
4141
// Add requested scopes if provided
4242
if len(discovery.Scopes) > 0 {
4343
registration.Scope = joinScopes(discovery.Scopes)
44-
} else {
4544
}
4645

4746
// Marshal the registration request

dcr_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package oauth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
)
11+
12+
// TestPerformDCR_PublicClient verifies Dynamic Client Registration
13+
// for public clients (no client secret)
14+
func TestPerformDCR_PublicClient(t *testing.T) {
15+
var capturedRequest *DCRRequest
16+
17+
// Mock registration endpoint
18+
regServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
// Capture and verify the request
20+
body, _ := io.ReadAll(r.Body)
21+
_ = json.Unmarshal(body, &capturedRequest)
22+
23+
// Return successful registration response
24+
_ = json.NewEncoder(w).Encode(DCRResponse{
25+
ClientID: "test-client-id-123",
26+
TokenEndpointAuthMethod: "none",
27+
GrantTypes: []string{"authorization_code", "refresh_token"},
28+
RedirectURIs: []string{"https://mcp.docker.com/oauth/callback"},
29+
})
30+
}))
31+
defer regServer.Close()
32+
33+
// Create discovery with registration endpoint
34+
discovery := &Discovery{
35+
RegistrationEndpoint: regServer.URL,
36+
AuthorizationEndpoint: "https://auth.example.com/authorize",
37+
TokenEndpoint: "https://auth.example.com/token",
38+
ResourceURL: "https://api.example.com",
39+
Scopes: []string{"read", "write"},
40+
}
41+
42+
// Perform DCR
43+
creds, err := PerformDCR(context.Background(), discovery, "test-server")
44+
// Verify no error
45+
if err != nil {
46+
t.Fatalf("DCR failed: %v", err)
47+
}
48+
49+
// Verify credentials
50+
if creds.ClientID != "test-client-id-123" {
51+
t.Errorf("Expected ClientID=test-client-id-123, got %s", creds.ClientID)
52+
}
53+
if !creds.IsPublic {
54+
t.Error("Expected IsPublic=true for public client")
55+
}
56+
if creds.ServerURL != "https://api.example.com" {
57+
t.Errorf("Expected ServerURL=https://api.example.com, got %s", creds.ServerURL)
58+
}
59+
60+
// Verify DCR request was correct
61+
if capturedRequest == nil {
62+
t.Fatal("DCR request not captured")
63+
}
64+
if capturedRequest.TokenEndpointAuthMethod != "none" {
65+
t.Errorf("Expected token_endpoint_auth_method=none for public client, got %s", capturedRequest.TokenEndpointAuthMethod)
66+
}
67+
if len(capturedRequest.RedirectURIs) == 0 {
68+
t.Error("Expected redirect_uris to be set")
69+
}
70+
if len(capturedRequest.GrantTypes) == 0 {
71+
t.Error("Expected grant_types to be set")
72+
}
73+
}
74+
75+
// TestPerformDCR_NoRegistrationEndpoint verifies error handling
76+
// when registration endpoint is not available
77+
func TestPerformDCR_NoRegistrationEndpoint(t *testing.T) {
78+
// Create discovery WITHOUT registration endpoint
79+
discovery := &Discovery{
80+
AuthorizationEndpoint: "https://auth.example.com/authorize",
81+
TokenEndpoint: "https://auth.example.com/token",
82+
RegistrationEndpoint: "", // Empty - DCR not supported
83+
}
84+
85+
// Attempt DCR
86+
creds, err := PerformDCR(context.Background(), discovery, "test-server")
87+
88+
// Verify error occurred
89+
if err == nil {
90+
t.Fatal("Expected error when registration endpoint missing")
91+
}
92+
if creds != nil {
93+
t.Error("Expected nil credentials on error")
94+
}
95+
}

discovery.go

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,21 @@ import (
2121
// - Gracefully handles servers with partial MCP compliance
2222
//
2323
// ROBUST DISCOVERY FLOW (Inspector-inspired):
24-
// 1. Make request to MCP server to trigger 401 response
25-
// 2. Default authorization server to MCP server domain
26-
// 3. Try to parse WWW-Authenticate header for resource_metadata URL
27-
// 4. If resource metadata available, try to fetch it (optional)
28-
// 5. Always fetch Authorization Server Metadata (required)
29-
// 6. Build discovery result with whatever information is available
24+
// 1. Make initial MCP request (expect 401 if OAuth required)
25+
// 2. Parse WWW-Authenticate header (if present)
26+
// 3. Initialize with intelligent defaults (fallback auth server = MCP domain)
27+
// 4. Fetch resource metadata (from header URL or well-known endpoint fallback)
28+
// 5. Fetch Authorization Server Metadata (REQUIRED)
29+
// 6. Build discovery result with all gathered information
30+
//
31+
// FALLBACK BEHAVIOR: If WWW-Authenticate missing/unparseable, falls back to
32+
// RFC 9728-required /.well-known/oauth-protected-resource endpoint
3033
func DiscoverOAuthRequirements(ctx context.Context, serverURL string) (*Discovery, error) {
34+
// Extract logger from context (or use noop if not provided)
35+
logger := loggerFromContext(ctx)
36+
37+
logger.Infof("starting OAuth discovery for server: %s", serverURL)
38+
3139
// Create HTTP client with reasonable timeout
3240
client := &http.Client{
3341
Timeout: 30 * time.Second,
@@ -59,28 +67,38 @@ func DiscoverOAuthRequirements(ctx context.Context, serverURL string) (*Discover
5967
}
6068
defer resp.Body.Close()
6169

62-
// If not 401, OAuth is not required (Authorization is OPTIONAL per MCP spec Section 2.1)
70+
logger.Infof("MCP server response: status=%d", resp.StatusCode)
71+
72+
// If not 401, OAuth might not be required (Authorization is OPTIONAL per MCP spec Section 2.1)
73+
// We log a warning but continue discovery attempt in case server is misconfigured
6374
if resp.StatusCode != http.StatusUnauthorized {
64-
return &Discovery{
65-
RequiresOAuth: false,
66-
}, nil
75+
logger.Warnf("expected 401 Unauthorized, got %d - OAuth may not be required", resp.StatusCode)
6776
}
6877

6978
// STEP 2: Parse WWW-Authenticate header (if present)
7079
// MCP Spec Section 4.1: "MCP servers MUST use the HTTP header WWW-Authenticate when returning a 401 Unauthorized"
7180
wwwAuth := resp.Header.Get("WWW-Authenticate")
72-
if wwwAuth == "" {
73-
return nil, fmt.Errorf("server returned 401 but no WWW-Authenticate header")
74-
}
7581

76-
challenges, err := ParseWWWAuthenticate(wwwAuth)
77-
if err != nil {
78-
return nil, fmt.Errorf("parsing WWW-Authenticate header: %w", err)
82+
var challenges []WWWAuthenticateChallenge
83+
if wwwAuth != "" {
84+
logger.Infof("WWW-Authenticate header present: %s", wwwAuth)
85+
var err error
86+
challenges, err = ParseWWWAuthenticate(wwwAuth)
87+
if err != nil {
88+
// WWW-Authenticate header exists but isn't parseable - log but continue
89+
logger.Warnf("could not parse WWW-Authenticate header: %v", err)
90+
challenges = nil
91+
} else {
92+
logger.Infof("parsed %d WWW-Authenticate challenge(s)", len(challenges))
93+
}
94+
} else {
95+
logger.Infof("no WWW-Authenticate header present - will try well-known endpoint")
7996
}
8097

8198
// STEP 3: Initialize with intelligent defaults (Inspector pattern)
8299
// Default authorization server to MCP server's domain
83100
defaultAuthServerURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
101+
logger.Debugf("default authorization server: %s", defaultAuthServerURL)
84102

85103
// Initialize discovery with defaults
86104
var resourceMetadata *ProtectedResourceMetadata
@@ -89,29 +107,43 @@ func DiscoverOAuthRequirements(ctx context.Context, serverURL string) (*Discover
89107

90108
// STEP 4: Try to get resource metadata (OPTIONAL - don't fail if missing)
91109
// RFC 9728 Section 5.1: resource_metadata parameter in WWW-Authenticate
92-
resourceMetadataURL := FindResourceMetadataURL(challenges)
110+
resourceMetadataURL := ""
111+
if challenges != nil {
112+
resourceMetadataURL = FindResourceMetadataURL(challenges)
113+
}
114+
93115
if resourceMetadataURL != "" {
94116
// Resource metadata URL found - try to fetch it
117+
logger.Infof("fetching protected resource metadata from: %s", resourceMetadataURL)
95118
resourceMetadata, resourceMetadataError = fetchOAuthProtectedResourceMetadata(ctx, client, resourceMetadataURL)
96119
if resourceMetadataError == nil && resourceMetadata != nil && resourceMetadata.AuthorizationServer != "" {
97120
// Use authorization server from resource metadata if available
98121
authServerURL = resourceMetadata.AuthorizationServer
122+
logger.Infof("resource metadata retrieved, auth server: %s", authServerURL)
123+
} else if resourceMetadataError != nil {
124+
logger.Warnf("failed to fetch resource metadata: %v", resourceMetadataError)
99125
}
100126
} else {
101127
// No resource_metadata in WWW-Authenticate - try well-known endpoint
102128
wellKnownURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", defaultAuthServerURL)
129+
logger.Infof("fallback: trying well-known resource metadata endpoint: %s", wellKnownURL)
103130
resourceMetadata, resourceMetadataError = fetchOAuthProtectedResourceMetadata(ctx, client, wellKnownURL)
104131
if resourceMetadataError == nil && resourceMetadata != nil && resourceMetadata.AuthorizationServer != "" {
105132
authServerURL = resourceMetadata.AuthorizationServer
133+
logger.Infof("resource metadata from well-known endpoint, auth server: %s", authServerURL)
106134
}
107135
}
108136

109137
// STEP 5: Fetch Authorization Server Metadata (REQUIRED)
110138
// MCP Spec Section 3.1: "Authorization servers MUST provide OAuth 2.0 Authorization Server Metadata (RFC8414)"
139+
logger.Infof("fetching authorization server metadata from: %s", authServerURL)
111140
authServerMetadata, err := fetchAuthorizationServerMetadata(ctx, client, authServerURL)
112141
if err != nil {
142+
logger.Warnf("failed to fetch authorization server metadata: %v", err)
113143
return nil, fmt.Errorf("fetching authorization server metadata from %s: %w", authServerURL, err)
114144
}
145+
logger.Infof("auth server metadata retrieved: token_endpoint=%s, registration_endpoint=%s",
146+
authServerMetadata.TokenEndpoint, authServerMetadata.RegistrationEndpoint)
115147

116148
// STEP 6: Build discovery result with all available information
117149
discovery := &Discovery{
@@ -155,6 +187,9 @@ func DiscoverOAuthRequirements(ctx context.Context, serverURL string) (*Discover
155187
discovery.Scopes = FindRequiredScopes(challenges)
156188
}
157189

190+
logger.Infof("discovery complete: auth_server=%s, scopes=%v, pkce=%v",
191+
discovery.AuthorizationServer, discovery.Scopes, discovery.SupportsPKCE)
192+
158193
return discovery, nil
159194
}
160195

0 commit comments

Comments
 (0)