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:
parent
14af2951e5
commit
4906620241
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue