Skip to content

DCR rejects RFC 8414 §3.1 path-insertion discoveryUrl with issuer-mismatch error #5390

@tgrunnagle

Description

@tgrunnagle

DCR discovery rejects RFC 8414 §3.1 path-insertion discoveryUrl with issuer-mismatch error

Summary

When dcrConfig.discoveryUrl points at an RFC 8414 §3.1 path-insertion URL — where the well-known suffix is inserted between host and the issuer's path (e.g. https://example.com/.well-known/oauth-authorization-server/v1/mcp for an issuer https://example.com/v1/mcp) — DCR fails with:

issuer mismatch (RFC 8414 §3.3): expected "https://mcp.us5.datadoghq.com", got "https://mcp.us5.datadoghq.com/v1/mcp"

ToolHive recovers the expected issuer by stripping /.well-known/... from the URL only when that suffix is at the end of the path. For the path-insertion form, the suffix is in the middle of the path, so the strip falls into the origin-only fallback and the §3.3 equality check rejects the AS's correctly-formed metadata. Currently ToolHive only accepts the legacy "well-known at root" and the OIDC issuer-suffix conventions.

Datadog's MCP server (mcp.us5.datadoghq.com) is one such path-aware authorization server, so any user pointing ToolHive at a Datadog-hosted MCP server via discoveryUrl is blocked.

Per the spec

RFC 8414 §3.1 says the well-known URI is formed by inserting the well-known path component between host and the issuer's path component, e.g.:

  • Issuer: https://example.com/v1/mcp
  • Well-known URL: https://example.com/.well-known/oauth-authorization-server/v1/mcp

§3.3 then requires the metadata's issuer field to equal the original issuer (https://example.com/v1/mcp), component-wise — host and path. ToolHive currently compares against host only for this URL shape.

Reproduction

dcr_config.yaml:

dcr_config:
  discovery_url: https://mcp.us5.datadoghq.com/.well-known/oauth-authorization-server/v1/mcp

Expected: discovery succeeds; DCR proceeds; the workload enters running state.

Actual:

issuer mismatch (RFC 8414 §3.3): expected "https://mcp.us5.datadoghq.com", got "https://mcp.us5.datadoghq.com/v1/mcp"

Root cause

deriveExpectedIssuerFromDiscoveryURL in pkg/auth/dcr/resolver.go:920-946 handles only two URL shapes:

switch {
case strings.HasSuffix(u.Path, oauthSuffix): // "/.well-known/oauth-authorization-server" at end
    u.Path = strings.TrimSuffix(u.Path, oauthSuffix)
case strings.HasSuffix(u.Path, oidcSuffix):  // "/.well-known/openid-configuration" at end
    u.Path = strings.TrimSuffix(u.Path, oidcSuffix)
default:
    // Custom (non-well-known) discovery URL — fall back to origin.
    u.Path = ""
}

For https://mcp.us5.datadoghq.com/.well-known/oauth-authorization-server/v1/mcp:

  • Path = /.well-known/oauth-authorization-server/v1/mcp
  • Neither HasSuffix matches — the suffix is followed by /v1/mcp.
  • Falls into defaultu.Path = "" → returns origin https://mcp.us5.datadoghq.com.

The AS returns metadata with issuer: "https://mcp.us5.datadoghq.com/v1/mcp". The §3.3 check in pkg/oauthproto/discovery.go:314-316 then rejects it.

The gap is explicitly acknowledged in the function's doc comment at pkg/auth/dcr/resolver.go:916-919:

RFC 8414 §3.1's path-aware form (well-known path inserted between host and tenant path, e.g. https://example.com/.well-known/oauth-authorization-server/tenant) is not auto-detected here — operators on that pattern can switch to dcr_config.registration_endpoint to bypass discovery.

This issue tracks closing that gap so the path-insertion form works without operators having to fall back to registration_endpoint.

Suggested fix

In deriveExpectedIssuerFromDiscoveryURL (pkg/auth/dcr/resolver.go), recognize the path-insertion form by checking whether /.well-known/oauth-authorization-server or /.well-known/openid-configuration appears inside the path (not only as a suffix). When the inner-suffix form matches, the trailing segment after the well-known path is the issuer's path component, e.g.:

  • /.well-known/oauth-authorization-server/v1/mcp → issuer path = /v1/mcp
  • /.well-known/openid-configuration/tenant/acme → issuer path = /tenant/acme

Care needed:

  • Resolve ambiguity with the existing issuer-suffix form (/tenants/acme/.well-known/oauth-authorization-server): prefer the suffix-at-end interpretation when the path ends with the well-known component, fall through to the path-insertion interpretation only when there is content after the well-known component.
  • The well-known segment must appear with a leading / and be a full path component (not a substring) to avoid false positives.

Test coverage

Add cases to TestDeriveExpectedIssuerFromDiscoveryURL in pkg/auth/dcr/resolver_test.go:

{
    name:         "oauth path-insertion (RFC 8414 §3.1) with tenant path suffix",
    discoveryURL: "https://mcp.us5.datadoghq.com/.well-known/oauth-authorization-server/v1/mcp",
    want:         "https://mcp.us5.datadoghq.com/v1/mcp",
},
{
    name:         "oidc path-insertion with tenant path suffix",
    discoveryURL: "https://idp.example.com/.well-known/openid-configuration/tenant/acme",
    want:         "https://idp.example.com/tenant/acme",
},

Also add an end-to-end regression test in pkg/auth/discovery/dcr_resolver_test.go mirroring the style of TestHandleDynamicRegistration_NonRootIssuerRFC8414PathInsertion (added in #5357 for the auto-discovery branch) but exercising the operator-configured DiscoveryURL branch.

Workaround

Until this is fixed, operators targeting an AS that only exposes the path-insertion form can bypass discovery by setting dcr_config.registration_endpoint directly (and supplying authorization_endpoint, token_endpoint, and scopes explicitly).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    authbugSomething isn't workinggoPull requests that update go codeoauth

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions