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 <noreply@anthropic.com>
Signed-off-by: Mike Costas <mike.costas@gmail.com>
This commit is contained in:
Mike Costas 2026-08-03 13:45:19 -07:00
parent 14af2951e5
commit 4906620241
1 changed files with 66 additions and 9 deletions

View File

@ -47,6 +47,7 @@ const (
applicationJSON = "application/json"
robotsPath = "/robots.txt"
protectedResourceMetadataPath = "/.well-known/oauth-protected-resource"
signInPath = "/sign_in"
signOutPath = "/sign_out"
oauthStartPath = "/start"
@ -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) {