Skip to content

Commit f89b103

Browse files
authored
Fix remote MCP server authentication with issuer mismatch (#1980)
This fixes authentication failures when remote MCP servers return a different issuer URL than their public-facing URL in the OIDC discovery response. The fix adds a new priority level to the issuer discovery algorithm, placing well-known endpoint discovery before URL derivation. This allows accepting the authoritative issuer from the .well-known/openid-configuration endpoint even when it differs from the server URL, complying with RFC 8414. New discovery priority chain: 1. Configured issuer (if provided) 2. Realm-derived issuer (RFC 8414) 3. Resource metadata discovery (RFC 9728) 4. Well-known endpoint discovery (NEW - accepts actual issuer) 5. URL-derived issuer (fallback) Changes: - Add tryDiscoverFromWellKnown to attempt discovery before URL derivation - Accept the actual issuer from ValidateAndDiscoverAuthServer - Preserve HTTP scheme for localhost URLs in DeriveIssuerFromURL - Add path normalization to DeriveIssuerFromRealm for security - Add comprehensive test coverage for discovery priority chain - Fix linting issues with constants and parallel test execution The fix maintains backward compatibility while supporting real-world OAuth deployments where the issuer differs from the server endpoint. Fixes #1957 Signed-off-by: Juan Antonio Osorio <[email protected]>
1 parent 7a8afd1 commit f89b103

File tree

5 files changed

+910
-19
lines changed

5 files changed

+910
-19
lines changed

pkg/auth/discovery/discovery.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"io"
1616
"net/http"
1717
"net/url"
18+
"path"
1819
"strings"
1920
"time"
2021

@@ -252,9 +253,16 @@ func DeriveIssuerFromURL(remoteURL string) string {
252253
host = fmt.Sprintf("%s:%s", host, port)
253254
}
254255

256+
// For localhost, preserve the original scheme (HTTP or HTTPS)
257+
// This supports local development and testing scenarios
258+
scheme := networking.HttpsScheme
259+
if networking.IsLocalhost(host) && parsedURL.Scheme != "" {
260+
scheme = parsedURL.Scheme
261+
}
262+
255263
// General pattern: use the domain as the issuer
256264
// This works for most OAuth providers that use their domain as the issuer
257-
issuer := fmt.Sprintf("%s://%s", networking.HttpsScheme, host)
265+
issuer := fmt.Sprintf("%s://%s", scheme, host)
258266

259267
logger.Debugf("Derived issuer from URL - remoteURL: %s, issuer: %s", remoteURL, issuer)
260268
return issuer
@@ -327,11 +335,22 @@ func DeriveIssuerFromRealm(realm string) string {
327335

328336
// RFC 8414: The issuer identifier MUST be a URL using the "https" scheme
329337
// with no query or fragment components
330-
if parsedURL.Scheme != "https" {
338+
if parsedURL.Scheme != "https" && !networking.IsLocalhost(parsedURL.Host) {
331339
logger.Debugf("Realm is not using HTTPS scheme: %s", realm)
332340
return ""
333341
}
334342

343+
// Normalize the path to prevent path traversal attacks
344+
if parsedURL.Path != "" {
345+
// Clean the path to resolve . and .. elements
346+
cleanPath := path.Clean(parsedURL.Path)
347+
// Ensure the path doesn't escape the root
348+
if !strings.HasPrefix(cleanPath, "/") {
349+
cleanPath = "/" + cleanPath
350+
}
351+
parsedURL.Path = cleanPath
352+
}
353+
335354
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
336355
logger.Debugf("Realm contains query or fragment components: %s", realm)
337356
// Remove query and fragment to make it a valid issuer

pkg/runner/config_test.go

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import (
2121
"github.com/stacklok/toolhive/pkg/transport/types"
2222
)
2323

24+
const (
25+
localhostStr = "localhost"
26+
)
27+
2428
func TestNewRunConfig(t *testing.T) {
2529
t.Parallel()
2630
config := NewRunConfig()
@@ -531,13 +535,13 @@ func TestRunConfigBuilder(t *testing.T) {
531535
TargetPort: 9090,
532536
Args: []string{"--metadata-arg"},
533537
}
534-
host := "localhost"
538+
host := localhostStr
535539
debug := true
536540
volumes := []string{"/host:/container"}
537541
secretsList := []string{"secret1,target=ENV_VAR1"}
538542
authzConfigPath := "" // Empty to skip loading the authorization configuration
539543
permissionProfile := permissions.ProfileNone
540-
targetHost := "localhost"
544+
targetHost := localhostStr
541545
mcpTransport := "sse"
542546
proxyPort := 60000
543547
targetPort := 9000
@@ -803,8 +807,8 @@ func TestRunConfigBuilder_MetadataOverrides(t *testing.T) {
803807
WithCmdArgs(nil),
804808
WithName("test-server"),
805809
WithImage("test-image"),
806-
WithHost("localhost"),
807-
WithTargetHost("localhost"),
810+
WithHost(localhostStr),
811+
WithTargetHost(localhostStr),
808812
WithDebug(false),
809813
WithVolumes(nil),
810814
WithSecrets(nil),
@@ -850,8 +854,8 @@ func TestRunConfigBuilder_EnvironmentVariableTransportDependency(t *testing.T) {
850854
WithCmdArgs(nil),
851855
WithName("test-server"),
852856
WithImage("test-image"),
853-
WithHost("localhost"),
854-
WithTargetHost("localhost"),
857+
WithHost(localhostStr),
858+
WithTargetHost(localhostStr),
855859
WithDebug(false),
856860
WithVolumes(nil),
857861
WithSecrets(nil),
@@ -902,8 +906,8 @@ func TestRunConfigBuilder_CmdArgsMetadataOverride(t *testing.T) {
902906
WithCmdArgs(userArgs),
903907
WithName("test-server"),
904908
WithImage("test-image"),
905-
WithHost("localhost"),
906-
WithTargetHost("localhost"),
909+
WithHost(localhostStr),
910+
WithTargetHost(localhostStr),
907911
WithDebug(false),
908912
WithVolumes(nil),
909913
WithSecrets(nil),
@@ -957,8 +961,8 @@ func TestRunConfigBuilder_CmdArgsMetadataDefaults(t *testing.T) {
957961
WithCmdArgs(userArgs),
958962
WithName("test-server"),
959963
WithImage("test-image"),
960-
WithHost("localhost"),
961-
WithTargetHost("localhost"),
964+
WithHost(localhostStr),
965+
WithTargetHost(localhostStr),
962966
WithDebug(false),
963967
WithVolumes(nil),
964968
WithSecrets(nil),
@@ -1008,8 +1012,8 @@ func TestRunConfigBuilder_VolumeProcessing(t *testing.T) {
10081012
WithCmdArgs(nil),
10091013
WithName("test-server"),
10101014
WithImage("test-image"),
1011-
WithHost("localhost"),
1012-
WithTargetHost("localhost"),
1015+
WithHost(localhostStr),
1016+
WithTargetHost(localhostStr),
10131017
WithDebug(false),
10141018
WithVolumes(volumes),
10151019
WithSecrets(nil),
@@ -1081,8 +1085,8 @@ func TestRunConfigBuilder_FilesystemMCPScenario(t *testing.T) {
10811085
WithCmdArgs(userArgs),
10821086
WithName("filesystem"),
10831087
WithImage("mcp/filesystem:latest"),
1084-
WithHost("localhost"),
1085-
WithTargetHost("localhost"),
1088+
WithHost(localhostStr),
1089+
WithTargetHost(localhostStr),
10861090
WithDebug(false),
10871091
WithVolumes(nil),
10881092
WithSecrets(nil),

pkg/runner/remote_auth.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,19 @@ func (h *RemoteAuthHandler) discoverIssuerAndScopes(
114114
return h.tryDiscoverFromResourceMetadata(ctx, authInfo.ResourceMetadata)
115115
}
116116

117-
issuer := discovery.DeriveIssuerFromURL(remoteURL)
118-
if issuer != "" {
119-
return issuer, h.config.Scopes, nil, nil
117+
// Priority 4: Try to discover actual issuer from the server's well-known endpoint
118+
// This handles cases where the issuer differs from the server URL (e.g., Atlassian)
119+
issuer, scopes, authServerInfo, err := h.tryDiscoverFromWellKnown(ctx, remoteURL)
120+
if err == nil {
121+
return issuer, scopes, authServerInfo, nil
122+
}
123+
logger.Debugf("Could not discover from well-known endpoint: %v", err)
124+
125+
// Priority 5: Last resort - derive issuer from URL without discovery
126+
derivedIssuer := discovery.DeriveIssuerFromURL(remoteURL)
127+
if derivedIssuer != "" {
128+
logger.Infof("Using derived issuer from URL: %s", derivedIssuer)
129+
return derivedIssuer, h.config.Scopes, nil, nil
120130
}
121131

122132
// No issuer could be determined
@@ -183,3 +193,40 @@ func (*RemoteAuthHandler) findValidAuthServer(
183193

184194
return nil, ""
185195
}
196+
197+
// tryDiscoverFromWellKnown attempts to discover the actual OAuth issuer
198+
// by probing the server's well-known endpoints without validating issuer match
199+
// This is useful when the issuer differs from the server URL (e.g., Atlassian case)
200+
func (h *RemoteAuthHandler) tryDiscoverFromWellKnown(
201+
ctx context.Context,
202+
remoteURL string,
203+
) (string, []string, *discovery.AuthServerInfo, error) {
204+
// First try to derive a base URL from the remote URL
205+
derivedURL := discovery.DeriveIssuerFromURL(remoteURL)
206+
if derivedURL == "" {
207+
return "", nil, nil, fmt.Errorf("could not derive base URL from %s", remoteURL)
208+
}
209+
210+
// Try to discover the actual issuer without validation
211+
// This uses DiscoverActualIssuer which doesn't validate issuer match
212+
authServerInfo, err := discovery.ValidateAndDiscoverAuthServer(ctx, derivedURL)
213+
if err != nil {
214+
return "", nil, nil, fmt.Errorf("well-known discovery failed: %w", err)
215+
}
216+
217+
// Successfully discovered the actual issuer
218+
if authServerInfo.Issuer != derivedURL {
219+
logger.Infof("Discovered actual issuer: %s (differs from server URL: %s)",
220+
authServerInfo.Issuer, derivedURL)
221+
}
222+
223+
// Determine scopes - use configured or fall back to defaults
224+
scopes := h.config.Scopes
225+
if len(scopes) == 0 {
226+
// Use some reasonable defaults if no scopes configured
227+
scopes = []string{"openid", "profile"}
228+
logger.Debugf("No scopes configured, using defaults: %v", scopes)
229+
}
230+
231+
return authServerInfo.Issuer, scopes, authServerInfo, nil
232+
}

0 commit comments

Comments
 (0)