Skip to content

Commit dc700b1

Browse files
authored
Make OAuth Redirect URI Configurable (#3)
* remove hard-coded URL * restrict to localhost or docker URLs
1 parent c15705d commit dc700b1

File tree

2 files changed

+130
-6
lines changed

2 files changed

+130
-6
lines changed

dcr.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,67 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"net/url"
1011
)
1112

1213
const DefaultRedirectURI = "https://mcp.docker.com/oauth/callback"
1314

15+
// isValidRedirectURI validates that the redirect URI is allowed for this library
16+
// Only localhost and mcp.docker.com are permitted for security
17+
func isValidRedirectURI(redirectURI string) error {
18+
if redirectURI == "" {
19+
return nil // Empty is OK (will use default)
20+
}
21+
22+
parsed, err := url.Parse(redirectURI)
23+
if err != nil {
24+
return fmt.Errorf("invalid redirect URI format: %w", err)
25+
}
26+
27+
// Extract hostname (handles ports automatically)
28+
hostname := parsed.Hostname()
29+
30+
// Allow localhost variations
31+
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
32+
return nil
33+
}
34+
35+
// Allow mcp.docker.com (production)
36+
if hostname == "mcp.docker.com" {
37+
return nil
38+
}
39+
40+
return fmt.Errorf("redirect URI host %q not allowed - must be localhost or mcp.docker.com", hostname)
41+
}
42+
1443
// PerformDCR performs Dynamic Client Registration with the authorization server
1544
// Returns client credentials for the registered public client
1645
//
1746
// RFC 7591 COMPLIANCE:
1847
// - Uses token_endpoint_auth_method="none" for public clients
1948
// - Includes redirect_uris pointing to mcp-oauth proxy
2049
// - Requests authorization_code and refresh_token grant types
21-
func PerformDCR(ctx context.Context, discovery *Discovery, serverName string) (*ClientCredentials, error) {
50+
//
51+
// redirectURI: The OAuth callback URI to register. If empty, uses DefaultRedirectURI.
52+
func PerformDCR(ctx context.Context, discovery *Discovery, serverName string, redirectURI string) (*ClientCredentials, error) {
2253
if discovery.RegistrationEndpoint == "" {
2354
return nil, fmt.Errorf("no registration endpoint found for %s", serverName)
2455
}
2556

57+
// Validate redirect URI for security (only localhost or mcp.docker.com allowed)
58+
if err := isValidRedirectURI(redirectURI); err != nil {
59+
return nil, fmt.Errorf("invalid redirect URI: %w", err)
60+
}
61+
62+
// Use provided redirectURI, fallback to default if empty
63+
if redirectURI == "" {
64+
redirectURI = DefaultRedirectURI
65+
}
66+
2667
// Build DCR request for PUBLIC client
2768
registration := DCRRequest{
2869
ClientName: fmt.Sprintf("MCP Gateway - %s", serverName),
29-
RedirectURIs: []string{DefaultRedirectURI},
70+
RedirectURIs: []string{redirectURI},
3071
TokenEndpointAuthMethod: "none", // PUBLIC client (no client secret)
3172
GrantTypes: []string{"authorization_code", "refresh_token"},
3273
ResponseTypes: []string{"code"},

dcr_test.go

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ func TestPerformDCR_PublicClient(t *testing.T) {
3939
Scopes: []string{"read", "write"},
4040
}
4141

42-
// Perform DCR
43-
creds, err := PerformDCR(context.Background(), discovery, "test-server")
42+
// Perform DCR (empty redirectURI uses default)
43+
creds, err := PerformDCR(context.Background(), discovery, "test-server", "")
4444
// Verify no error
4545
if err != nil {
4646
t.Fatalf("DCR failed: %v", err)
@@ -82,8 +82,8 @@ func TestPerformDCR_NoRegistrationEndpoint(t *testing.T) {
8282
RegistrationEndpoint: "", // Empty - DCR not supported
8383
}
8484

85-
// Attempt DCR
86-
creds, err := PerformDCR(context.Background(), discovery, "test-server")
85+
// Attempt DCR (empty redirectURI uses default)
86+
creds, err := PerformDCR(context.Background(), discovery, "test-server", "")
8787

8888
// Verify error occurred
8989
if err == nil {
@@ -93,3 +93,86 @@ func TestPerformDCR_NoRegistrationEndpoint(t *testing.T) {
9393
t.Error("Expected nil credentials on error")
9494
}
9595
}
96+
97+
// TestIsValidRedirectURI verifies redirect URI validation logic
98+
func TestIsValidRedirectURI(t *testing.T) {
99+
tests := []struct {
100+
name string
101+
redirectURI string
102+
expectError bool
103+
description string
104+
}{
105+
{
106+
name: "empty string",
107+
redirectURI: "",
108+
expectError: false,
109+
description: "Empty string should be allowed (uses default)",
110+
},
111+
{
112+
name: "localhost http",
113+
redirectURI: "http://localhost:5000/callback",
114+
expectError: false,
115+
description: "Localhost with HTTP should be allowed",
116+
},
117+
{
118+
name: "localhost https",
119+
redirectURI: "https://localhost:5000/callback",
120+
expectError: false,
121+
description: "Localhost with HTTPS should be allowed",
122+
},
123+
{
124+
name: "127.0.0.1",
125+
redirectURI: "http://127.0.0.1:8080/callback",
126+
expectError: false,
127+
description: "127.0.0.1 should be allowed",
128+
},
129+
{
130+
name: "IPv6 localhost",
131+
redirectURI: "http://[::1]:8080/callback",
132+
expectError: false,
133+
description: "IPv6 localhost should be allowed",
134+
},
135+
{
136+
name: "mcp.docker.com production",
137+
redirectURI: "https://mcp.docker.com/oauth/callback",
138+
expectError: false,
139+
description: "Production mcp.docker.com should be allowed",
140+
},
141+
{
142+
name: "evil domain",
143+
redirectURI: "https://evil.com/callback",
144+
expectError: true,
145+
description: "Arbitrary domains should be blocked",
146+
},
147+
{
148+
name: "attacker ngrok",
149+
redirectURI: "https://attacker.ngrok.io/callback",
150+
expectError: true,
151+
description: "Attacker-controlled domains should be blocked",
152+
},
153+
{
154+
name: "subdomain of docker.com",
155+
redirectURI: "https://evil.docker.com/callback",
156+
expectError: true,
157+
description: "Only mcp.docker.com should be allowed, not subdomains",
158+
},
159+
{
160+
name: "invalid URL",
161+
redirectURI: "not-a-valid-url",
162+
expectError: true,
163+
description: "Invalid URL format should be rejected",
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
err := isValidRedirectURI(tt.redirectURI)
170+
if tt.expectError && err == nil {
171+
t.Errorf("Expected error for %q (%s)", tt.redirectURI, tt.description)
172+
}
173+
if !tt.expectError && err != nil {
174+
t.Errorf("Unexpected error for %q: %v (%s)", tt.redirectURI, err, tt.description)
175+
}
176+
})
177+
}
178+
}

0 commit comments

Comments
 (0)