From 4906620241646767077cdf616575ca2c662c7a1c Mon Sep 17 00:00:00 2001 From: Mike Costas Date: Mon, 3 Aug 2026 13:45:19 -0700 Subject: [PATCH] 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) {