diff --git a/oauthproxy.go b/oauthproxy.go index f8dc5471..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" @@ -46,14 +48,16 @@ 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" + dynamicClientRegistrationPath = "/register" + signInPath = "/sign_in" + signOutPath = "/sign_out" + oauthStartPath = "/start" + oauthCallbackPath = "/callback" + authOnlyPath = "/auth" + userInfoPath = "/userinfo" + staticPathPrefix = "/static/" idTokenPlaceholder = "{id_token}" ) @@ -118,6 +122,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 +263,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 +336,20 @@ 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) + + // 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. @@ -1057,7 +1082,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 +1369,121 @@ 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) +} + +// 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) {