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 <noreply@anthropic.com> Signed-off-by: Mike Costas <mike.costas@gmail.com>
This commit is contained in:
parent
4906620241
commit
b026a6eba8
|
|
@ -1,12 +1,14 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -48,6 +50,7 @@ const (
|
||||||
|
|
||||||
robotsPath = "/robots.txt"
|
robotsPath = "/robots.txt"
|
||||||
protectedResourceMetadataPath = "/.well-known/oauth-protected-resource"
|
protectedResourceMetadataPath = "/.well-known/oauth-protected-resource"
|
||||||
|
dynamicClientRegistrationPath = "/register"
|
||||||
signInPath = "/sign_in"
|
signInPath = "/sign_in"
|
||||||
signOutPath = "/sign_out"
|
signOutPath = "/sign_out"
|
||||||
oauthStartPath = "/start"
|
oauthStartPath = "/start"
|
||||||
|
|
@ -339,6 +342,14 @@ func (p *OAuthProxy) buildServeMux(proxyPrefix string) {
|
||||||
// ProxyPrefix. See ProtectedResourceMetadata.
|
// ProxyPrefix. See ProtectedResourceMetadata.
|
||||||
r.Path(protectedResourceMetadataPath).HandlerFunc(p.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 <this proxy's own origin>/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.
|
// 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
|
// 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.
|
// 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)
|
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,
|
// LoggingCSRFCookiesInOAuthCallback Log all CSRF cookies found in HTTP request OAuth callback,
|
||||||
// which were successfully parsed
|
// which were successfully parsed
|
||||||
func LoggingCSRFCookiesInOAuthCallback(req *http.Request, cookieName string) {
|
func LoggingCSRFCookiesInOAuthCallback(req *http.Request, cookieName string) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue