From 4906620241646767077cdf616575ca2c662c7a1c Mon Sep 17 00:00:00 2001 From: Mike Costas Date: Mon, 3 Aug 2026 13:45:19 -0700 Subject: [PATCH 1/2] Add RFC 9728 Protected Resource Metadata for MCP client compatibility MCP clients (Model Context Protocol, per its Authorization spec at modelcontextprotocol.io/specification/2025-06-18/basic/authorization) require an OAuth resource server to implement OAuth 2.0 Protected Resource Metadata (RFC 9728) and return a WWW-Authenticate header on 401 responses pointing at it. oauth2-proxy currently returns a bare 401 with an empty "{}" JSON body and no WWW-Authenticate header, which MCP clients cannot parse as a valid OAuth error at all - confirmed against a real deployment fronting an MCP server. - New /.well-known/oauth-protected-resource route (host root, not under ProxyPrefix - MCP clients build this URL themselves). - New unauthorizedJSON path used for the existing JSON-error branch (forceJSONErrors/isAjax/isAPIPath) in Proxy(), setting WWW-Authenticate and a non-empty OAuth-shaped error body. - Reuses the already-configured OIDC issuer as the advertised authorization server - no new CLI flags needed. See oauth2-proxy/oauth2-proxy#3167. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Mike Costas --- oauthproxy.go | 75 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/oauthproxy.go b/oauthproxy.go index f8dc5471..bed5d6d3 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -46,14 +46,15 @@ const ( schemeHTTPS = "https" applicationJSON = "application/json" - robotsPath = "/robots.txt" - signInPath = "/sign_in" - signOutPath = "/sign_out" - oauthStartPath = "/start" - oauthCallbackPath = "/callback" - authOnlyPath = "/auth" - userInfoPath = "/userinfo" - staticPathPrefix = "/static/" + robotsPath = "/robots.txt" + protectedResourceMetadataPath = "/.well-known/oauth-protected-resource" + signInPath = "/sign_in" + signOutPath = "/sign_out" + oauthStartPath = "/start" + oauthCallbackPath = "/callback" + authOnlyPath = "/auth" + userInfoPath = "/userinfo" + staticPathPrefix = "/static/" idTokenPlaceholder = "{id_token}" ) @@ -118,6 +119,12 @@ type OAuthProxy struct { appDirector redirect.AppDirector encodeState bool + + // oidcIssuerURL is the configured OIDC issuer, exposed via + // /.well-known/oauth-protected-resource (RFC 9728) so MCP-spec OAuth + // clients can discover the authorization server. See + // ProtectedResourceMetadata. + oidcIssuerURL string } // NewOAuthProxy creates a new instance of OAuthProxy from the options provided @@ -253,6 +260,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr redirectValidator: redirectValidator, appDirector: appDirector, encodeState: opts.EncodeState, + oidcIssuerURL: opts.Providers[0].OIDCConfig.IssuerURL, } p.buildServeMux(opts.ProxyPrefix) @@ -325,6 +333,12 @@ func (p *OAuthProxy) buildServeMux(proxyPrefix string) { // Register the robots path writer r.Path(robotsPath).HandlerFunc(p.pageWriter.WriteRobotsTxt) + // RFC 9728 Protected Resource Metadata MUST live at the host root, not + // under proxyPrefix (unlike sign_in/start/callback below) - MCP clients + // build this well-known URL themselves and don't know about our + // ProxyPrefix. See ProtectedResourceMetadata. + r.Path(protectedResourceMetadataPath).HandlerFunc(p.ProtectedResourceMetadata) + // 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. @@ -1057,7 +1071,7 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) { if p.forceJSONErrors || isAjax(req) || p.isAPIPath(req) { logger.Printf("No valid authentication in request. Access Denied.") // no point redirecting an AJAX request - p.errorJSON(rw, http.StatusUnauthorized) + p.unauthorizedJSON(rw, req) return } @@ -1344,6 +1358,49 @@ func (p *OAuthProxy) errorJSON(rw http.ResponseWriter, code int) { rw.Write([]byte("{}")) } +// unauthorizedJSON returns a 401 with a WWW-Authenticate header pointing at +// this server's OAuth 2.0 Protected Resource Metadata document (RFC 9728), +// and a non-empty OAuth-shaped JSON error body (RFC 6750 section 3) - +// required for MCP clients (modelcontextprotocol.io/specification/ +// 2025-06-18/basic/authorization) to discover how to authenticate. Plain +// errorJSON's empty "{}" body and missing header left MCP clients unable +// to parse the response as a valid OAuth error at all. +func (p *OAuthProxy) unauthorizedJSON(rw http.ResponseWriter, req *http.Request) { + metadataURL := url.URL{ + Scheme: requestutil.GetRequestProto(req), + Host: requestutil.GetRequestHost(req), + Path: protectedResourceMetadataPath, + } + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, metadataURL.String())) + rw.Header().Set("Content-Type", applicationJSON) + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"unauthorized","error_description":"authentication required"}`)) +} + +// ProtectedResourceMetadata serves OAuth 2.0 Protected Resource Metadata +// (RFC 9728) at the well-known path MCP clients fetch after receiving a 401 +// with a WWW-Authenticate header (see unauthorizedJSON). Points clients at +// the OIDC issuer this proxy is already configured with - the issuer's own +// RFC 8414 Authorization Server Metadata (a document oauth2-proxy does not +// need to serve itself) tells the client where to actually authenticate. +func (p *OAuthProxy) ProtectedResourceMetadata(rw http.ResponseWriter, req *http.Request) { + resource := url.URL{ + Scheme: requestutil.GetRequestProto(req), + Host: requestutil.GetRequestHost(req), + } + body, err := json.Marshal(map[string]interface{}{ + "resource": resource.String(), + "authorization_servers": []string{p.oidcIssuerURL}, + }) + if err != nil { + p.errorJSON(rw, http.StatusInternalServerError) + return + } + rw.Header().Set("Content-Type", applicationJSON) + rw.WriteHeader(http.StatusOK) + rw.Write(body) +} + // LoggingCSRFCookiesInOAuthCallback Log all CSRF cookies found in HTTP request OAuth callback, // which were successfully parsed func LoggingCSRFCookiesInOAuthCallback(req *http.Request, cookieName string) { From b6818c10b699bb7b9d2ef3ce139334842e625396 Mon Sep 17 00:00:00 2001 From: Mike Costas Date: Tue, 4 Aug 2026 12:24:06 -0700 Subject: [PATCH 2/2] 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) {