Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 136 additions & 6 deletions client/transport/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ type OAuthHandler struct {
baseURL string
resourceURL string // RFC 8707 resource indicator; set from protected resource metadata

mu sync.RWMutex // Protects expectedState
expectedState string // Expected state value for CSRF protection
mu sync.RWMutex // Protects expectedState and protectedResourceMetadataURL
expectedState string // Expected state value for CSRF protection
protectedResourceMetadataURL string // RFC 9728 §5.1: PRM URL advertised by the server via WWW-Authenticate
}

// NewOAuthHandler creates a new OAuth handler
Expand Down Expand Up @@ -318,6 +319,44 @@ func (h *OAuthHandler) SetBaseURL(baseURL string) {
h.baseURL = baseURL
}

// SetProtectedResourceMetadataURL stores the OAuth 2.0 Protected Resource
// Metadata URL advertised by the server. When set, metadata discovery
// fetches this URL in preference to constructing one from the base URL's
// /.well-known/oauth-protected-resource path.
//
// The transport layer calls this automatically when a 401 response carries
// a resource_metadata parameter in the WWW-Authenticate header per
// RFC 9728 §5.1. Callers may also set it explicitly when the URL is known
// out of band.
func (h *OAuthHandler) SetProtectedResourceMetadataURL(prmURL string) {
h.mu.Lock()
h.protectedResourceMetadataURL = prmURL
h.mu.Unlock()
}

// ProtectedResourceMetadataURL returns the Protected Resource Metadata URL
// that will be used during metadata discovery, or an empty string if none
// has been set.
func (h *OAuthHandler) ProtectedResourceMetadataURL() string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.protectedResourceMetadataURL
}

// HandleUnauthorizedResponse inspects a 401 response for an RFC 9728 §5.1
// WWW-Authenticate challenge and, when it contains a resource_metadata
// parameter, stores the URL so subsequent metadata discovery can use it.
// It is safe to call with a nil response and is a no-op when the header
// is absent or contains no resource_metadata parameter.
func (h *OAuthHandler) HandleUnauthorizedResponse(resp *http.Response) {
if resp == nil {
return
}
if u := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); u != "" {
h.SetProtectedResourceMetadataURL(u)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// GetExpectedState returns the expected state value (for testing purposes)
func (h *OAuthHandler) GetExpectedState() string {
h.mu.RLock()
Expand Down Expand Up @@ -380,10 +419,17 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada
return
}

protectedResourceURL, err := buildWellKnownURL(baseURL, "oauth-protected-resource")
if err != nil {
h.metadataFetchErr = fmt.Errorf("failed to build protected resource URL: %w", err)
return
// Prefer a PRM URL advertised via WWW-Authenticate (RFC 9728 §5.1)
// when the server provided one; this is required for deployments
// where the PRM endpoint sits under a path that origin-based
// construction cannot reach.
protectedResourceURL := h.ProtectedResourceMetadataURL()
if protectedResourceURL == "" {
protectedResourceURL, err = buildWellKnownURL(baseURL, "oauth-protected-resource")
if err != nil {
h.metadataFetchErr = fmt.Errorf("failed to build protected resource URL: %w", err)
return
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil)
if err != nil {
Expand Down Expand Up @@ -516,6 +562,90 @@ func buildWellKnownURL(baseURL string, suffix string) (string, error) {
return root + "/.well-known/" + suffix + path, nil
}

// extractResourceMetadataURL returns the resource_metadata parameter value
// from a WWW-Authenticate header per RFC 9728 §5.1, or an empty string when
// the header is empty, no such parameter is present, or the value is
// malformed. Parameter names are matched case-insensitively per
// RFC 9110 §11.2; both quoted-string and token value forms are accepted.
func extractResourceMetadataURL(header string) string {
const target = "resource_metadata"
i := 0
for i < len(header) {
// Advance to the next token start.
for i < len(header) && !isAuthTokenChar(header[i]) {
i++
}
nameStart := i
for i < len(header) && isAuthTokenChar(header[i]) {
i++
}
name := header[nameStart:i]
// Skip optional whitespace between the name and '='.
for i < len(header) && (header[i] == ' ' || header[i] == '\t') {
i++
}
if i >= len(header) || header[i] != '=' {
// Name was a scheme token (e.g. "Bearer"), not a parameter.
continue
}
// Skip '=' and optional whitespace.
i++
for i < len(header) && (header[i] == ' ' || header[i] == '\t') {
i++
}
value, next := parseAuthParamValue(header, i)
i = next
if strings.EqualFold(name, target) {
return value
}
}
return ""
}

// parseAuthParamValue reads a single WWW-Authenticate parameter value
// starting at offset i: a quoted-string (with backslash escapes) when the
// first byte is '"', otherwise a bare token. It returns the decoded value
// and the index of the first byte after it.
func parseAuthParamValue(s string, i int) (string, int) {
if i >= len(s) {
return "", i
}
if s[i] == '"' {
i++
var b strings.Builder
for i < len(s) {
c := s[i]
if c == '\\' && i+1 < len(s) {
b.WriteByte(s[i+1])
i += 2
continue
}
if c == '"' {
return b.String(), i + 1
}
b.WriteByte(c)
i++
}
return b.String(), i
}
start := i
for i < len(s) && isAuthTokenChar(s[i]) {
i++
}
return s[start:i], i
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// isAuthTokenChar reports whether c is a valid RFC 9110 §5.6.2 token
// character — the character class used for scheme and parameter names in
// WWW-Authenticate.
func isAuthTokenChar(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0
}

// fetchMetadataFromURL fetches and parses OAuth server metadata from a URL
func (h *OAuthHandler) fetchMetadataFromURL(ctx context.Context, metadataURL string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
Expand Down
190 changes: 190 additions & 0 deletions client/transport/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1755,3 +1755,193 @@ func TestOAuthHandler_GetServerMetadata_AuthServerReturnsHTML(t *testing.T) {
assert.Equal(t, authServer.URL+"/token", metadata.TokenEndpoint)
assert.Equal(t, authServer.URL+"/register", metadata.RegistrationEndpoint)
}

func TestExtractResourceMetadataURL(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
{
name: "empty header",
header: "",
want: "",
},
{
name: "bearer challenge with quoted resource_metadata",
header: `Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/tenant"`,
want: "https://example.com/.well-known/oauth-protected-resource/tenant",
},
{
name: "bearer challenge with realm, error, and resource_metadata",
header: `Bearer realm="mcp", error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"`,
want: "https://example.com/.well-known/oauth-protected-resource",
},
{
name: "parameter name matched case-insensitively",
header: `Bearer Resource_Metadata="https://example.com/prm"`,
want: "https://example.com/prm",
},
{
name: "unquoted token value",
header: `Bearer resource_metadata=abc123`,
want: "abc123",
},
{
name: "quoted value with escaped quote",
header: `Bearer resource_metadata="https://example.com/with-\"quote\""`,
want: `https://example.com/with-"quote"`,
},
{
name: "value with tabs and extra whitespace",
header: "Bearer\tresource_metadata\t=\t\"https://example.com/prm\"",
want: "https://example.com/prm",
},
{
name: "no resource_metadata parameter",
header: `Bearer realm="mcp", error="invalid_token"`,
want: "",
},
{
name: "word containing resource_metadata is not a match",
header: `Bearer realm="foo resource_metadata bar"`,
want: "",
},
{
name: "missing equals after parameter name",
header: `Bearer resource_metadata`,
want: "",
},
{
name: "truncated quoted value returns what was read",
header: `Bearer resource_metadata="https://example.com/prm`,
want: "https://example.com/prm",
},
{
name: "prefers first occurrence",
header: `Bearer resource_metadata="https://example.com/a", resource_metadata="https://example.com/b"`,
want: "https://example.com/a",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractResourceMetadataURL(tc.header)
assert.Equal(t, tc.want, got)
})
}
}

func TestOAuthHandler_ProtectedResourceMetadataURL_SetGet(t *testing.T) {
handler := NewOAuthHandler(OAuthConfig{
ClientID: "test-client",
RedirectURI: "http://localhost/callback",
})

assert.Equal(t, "", handler.ProtectedResourceMetadataURL(), "should default to empty")

handler.SetProtectedResourceMetadataURL("https://example.com/prm")
assert.Equal(t, "https://example.com/prm", handler.ProtectedResourceMetadataURL())

handler.SetProtectedResourceMetadataURL("")
assert.Equal(t, "", handler.ProtectedResourceMetadataURL(), "empty value should clear")
}

func TestOAuthHandler_HandleUnauthorizedResponse(t *testing.T) {
cases := []struct {
name string
response *http.Response
want string
}{
{
name: "nil response is a no-op",
response: nil,
want: "",
},
{
name: "no WWW-Authenticate header leaves PRM unset",
response: &http.Response{
Header: http.Header{},
},
want: "",
},
{
name: "bearer challenge without resource_metadata leaves PRM unset",
response: &http.Response{
Header: http.Header{
"Www-Authenticate": []string{`Bearer realm="mcp"`},
},
},
want: "",
},
{
name: "bearer challenge with resource_metadata stores the URL",
response: &http.Response{
Header: http.Header{
"Www-Authenticate": []string{`Bearer resource_metadata="https://example.com/prm"`},
},
},
want: "https://example.com/prm",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
handler := NewOAuthHandler(OAuthConfig{
ClientID: "test-client",
RedirectURI: "http://localhost/callback",
})
handler.HandleUnauthorizedResponse(tc.response)
assert.Equal(t, tc.want, handler.ProtectedResourceMetadataURL())
})
}
}

// TestOAuthHandler_GetServerMetadata_UsesAdvertisedPRMURL verifies that when
// the server has advertised a Protected Resource Metadata URL via
// WWW-Authenticate (RFC 9728 §5.1), discovery fetches that URL in preference
// to the origin-based /.well-known/oauth-protected-resource construction.
func TestOAuthHandler_GetServerMetadata_UsesAdvertisedPRMURL(t *testing.T) {
advertisedPRMRequested := false
wellKnownPRMRequested := false
authServerRequested := false

var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/custom/prm-path":
advertisedPRMRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthProtectedResource{
AuthorizationServers: []string{server.URL},
})
case "/.well-known/oauth-protected-resource":
wellKnownPRMRequested = true
w.WriteHeader(http.StatusNotFound)
case "/.well-known/oauth-authorization-server":
authServerRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(AuthServerMetadata{
Issuer: server.URL,
AuthorizationEndpoint: server.URL + "/authorize",
TokenEndpoint: server.URL + "/token",
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

handler := NewOAuthHandler(OAuthConfig{
ClientID: "test-client",
RedirectURI: "http://localhost/callback",
TokenStore: NewMemoryTokenStore(),
})
handler.SetBaseURL(server.URL)
handler.SetProtectedResourceMetadataURL(server.URL + "/custom/prm-path")

metadata, err := handler.GetServerMetadata(context.Background())
require.NoError(t, err)
assert.True(t, advertisedPRMRequested, "advertised PRM URL should be fetched")
assert.False(t, wellKnownPRMRequested, "well-known PRM URL should be skipped when an advertised one is set")
assert.True(t, authServerRequested, "authorization-server metadata should still be fetched")
assert.Equal(t, server.URL+"/token", metadata.TokenEndpoint)
}
3 changes: 3 additions & 0 deletions client/transport/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ func (c *SSE) Start(ctx context.Context) error {
// Handle unauthorized error
if resp.StatusCode == http.StatusUnauthorized {
if c.oauthHandler != nil {
c.oauthHandler.HandleUnauthorizedResponse(resp)
return &OAuthAuthorizationRequiredError{
Handler: c.oauthHandler,
}
Expand Down Expand Up @@ -486,6 +487,7 @@ func (c *SSE) SendRequest(
// Handle unauthorized error
if resp.StatusCode == http.StatusUnauthorized {
if c.oauthHandler != nil {
c.oauthHandler.HandleUnauthorizedResponse(resp)
return nil, &OAuthAuthorizationRequiredError{
Handler: c.oauthHandler,
}
Expand Down Expand Up @@ -634,6 +636,7 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti
// Handle unauthorized error
if resp.StatusCode == http.StatusUnauthorized {
if c.oauthHandler != nil {
c.oauthHandler.HandleUnauthorizedResponse(resp)
return &OAuthAuthorizationRequiredError{
Handler: c.oauthHandler,
}
Expand Down
Loading