From b6818c10b699bb7b9d2ef3ce139334842e625396 Mon Sep 17 00:00:00 2001 From: Mike Costas Date: Tue, 4 Aug 2026 12:24:06 -0700 Subject: [PATCH] Proxy RFC 7591 Dynamic Client Registration to the OIDC issuer MCP clients (confirmed against Claude Code) exhibit the same origin assumption this proxy's Protected Resource Metadata (RFC 9728, #3488) already works around for /authorize and /token: even after correctly discovering the real authorization server, they still POST their Dynamic Client Registration (RFC 7591) request to this proxy's own /register instead of the issuer's real advertised registration_endpoint. Adds a /register route at the host root (same placement rationale as protectedResourceMetadataPath - MCP clients build this URL themselves, without knowledge of ProxyPrefix) that discovers the issuer's real registration_endpoint from its .well-known/openid-configuration document and proxies the request there, relaying the response verbatim - including the Authorization header, so Initial-Access-Token- gated registration works too, not just anonymous. Depends on the oidcIssuerURL field added in #3488. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Mike Costas --- oauthproxy.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/oauthproxy.go b/oauthproxy.go index bed5d6d3..1de75974 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -1,12 +1,14 @@ package main import ( + "bytes" "context" "embed" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/url" @@ -48,6 +50,7 @@ const ( robotsPath = "/robots.txt" protectedResourceMetadataPath = "/.well-known/oauth-protected-resource" + dynamicClientRegistrationPath = "/register" signInPath = "/sign_in" signOutPath = "/sign_out" oauthStartPath = "/start" @@ -339,6 +342,14 @@ func (p *OAuthProxy) buildServeMux(proxyPrefix string) { // ProxyPrefix. See ProtectedResourceMetadata. r.Path(protectedResourceMetadataPath).HandlerFunc(p.ProtectedResourceMetadata) + // Also host root, not under proxyPrefix, for the same reason - MCP + // clients (confirmed against Claude Code) request RFC 7591 Dynamic + // Client Registration at /register rather + // than the authorization server's real advertised + // registration_endpoint, even after correctly resolving that server + // via RFC 9728/8414 discovery. See DynamicClientRegistration. + r.Path(dynamicClientRegistrationPath).HandlerFunc(p.DynamicClientRegistration) + // The authonly path should be registered separately to prevent it from getting no-cache headers. // We do this to allow users to have a short cache (via nginx) of the response to reduce the // likelihood of multiple requests trying to refresh sessions simultaneously. @@ -1401,6 +1412,78 @@ func (p *OAuthProxy) ProtectedResourceMetadata(rw http.ResponseWriter, req *http rw.Write(body) } +// DynamicClientRegistration proxies RFC 7591 Dynamic Client Registration +// requests to the configured OIDC issuer's own registration_endpoint +// (discovered from its .well-known/openid-configuration document) and +// relays the response verbatim, including its status code. +// +// MCP clients need this because of the same origin-assumption behavior +// ProtectedResourceMetadata's WWW-Authenticate header is meant to correct +// for authorization/token requests: confirmed against Claude Code, once it +// receives Protected Resource Metadata pointing at an external +// authorization server, it correctly directs the user's browser to that +// server's own /authorize and posts to its own /token - but it still POSTs +// its DCR request to THIS proxy's own /register instead of the issuer's +// real advertised registration_endpoint. Forwarding the Authorization +// header lets Initial-Access-Token-gated ("authenticated" RFC 7591) +// registration work too, not just anonymous. +func (p *OAuthProxy) DynamicClientRegistration(rw http.ResponseWriter, req *http.Request) { + client := &http.Client{Timeout: 10 * time.Second} + + discoveryResp, err := client.Get(strings.TrimSuffix(p.oidcIssuerURL, "/") + "/.well-known/openid-configuration") + if err != nil { + logger.Errorf("error fetching issuer discovery document for dynamic client registration: %v", err) + http.Error(rw, "failed to reach issuer", http.StatusBadGateway) + return + } + defer discoveryResp.Body.Close() + + var discovery struct { + RegistrationEndpoint string `json:"registration_endpoint"` + } + if err := json.NewDecoder(discoveryResp.Body).Decode(&discovery); err != nil || discovery.RegistrationEndpoint == "" { + logger.Errorf("issuer discovery document has no registration_endpoint: %v", err) + http.Error(rw, "issuer does not support dynamic client registration", http.StatusNotImplemented) + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, "invalid request body", http.StatusBadRequest) + return + } + + upstreamReq, err := http.NewRequest(http.MethodPost, discovery.RegistrationEndpoint, bytes.NewReader(body)) + if err != nil { + logger.Errorf("error building dynamic client registration request: %v", err) + http.Error(rw, "internal error", http.StatusInternalServerError) + return + } + upstreamReq.Header.Set("Content-Type", applicationJSON) + if auth := req.Header.Get("Authorization"); auth != "" { + upstreamReq.Header.Set("Authorization", auth) + } + + upstreamResp, err := client.Do(upstreamReq) + if err != nil { + logger.Errorf("error forwarding dynamic client registration request: %v", err) + http.Error(rw, "failed to reach issuer", http.StatusBadGateway) + return + } + defer upstreamResp.Body.Close() + + respBody, err := io.ReadAll(upstreamResp.Body) + if err != nil { + logger.Errorf("error reading dynamic client registration response: %v", err) + http.Error(rw, "internal error", http.StatusInternalServerError) + return + } + + rw.Header().Set("Content-Type", applicationJSON) + rw.WriteHeader(upstreamResp.StatusCode) + rw.Write(respBody) +} + // LoggingCSRFCookiesInOAuthCallback Log all CSRF cookies found in HTTP request OAuth callback, // which were successfully parsed func LoggingCSRFCookiesInOAuthCallback(req *http.Request, cookieName string) {