Change OAuthHandler signature from func(context.Context, OAuthHandlerArgs) to func(req *http.Request, res *http.Response). - Remove OAuthHandlerArgs struct - Update HTTPTransport to pass req and resp to handler - Update tests to use new signature - Handler can now call oauthex.GetProtectedResourceMetadataFromHeader with proper validation against request URL This change fixes an impedance mismatch between OAuthHandler and the protected resource metadata functions of the oauthex package. The new signature allows handlers to properly validate resource metadata against the request URL, as required by RFC 9728. Fixes #600
153 lines
4.6 KiB
Go
153 lines
4.6 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
//go:build mcp_go_client_oauth
|
|
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// A basicReader is an io.Reader to be used as a non-rereadable request body.
|
|
//
|
|
// net/http has special handling for strings.Reader that we want to avoid.
|
|
type basicReader struct {
|
|
r *strings.Reader
|
|
}
|
|
|
|
func (r *basicReader) Read(p []byte) (n int, err error) { return r.r.Read(p) }
|
|
|
|
// TestHTTPTransport validates the OAuth HTTPTransport.
|
|
func TestHTTPTransport(t *testing.T) {
|
|
const testToken = "test-token-123"
|
|
fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
|
|
AccessToken: testToken,
|
|
TokenType: "Bearer",
|
|
})
|
|
|
|
// authServer simulates a resource that requires OAuth.
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
// Ensure that the body was properly cloned, by reading it completely.
|
|
// If the body is not cloned, reading it the second time may yield no
|
|
// bytes.
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
http.Error(w, "empty body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == fmt.Sprintf("Bearer %s", testToken) {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer authServer.Close()
|
|
|
|
t.Run("successful auth flow", func(t *testing.T) {
|
|
var handlerCalls int
|
|
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
|
handlerCalls++
|
|
// Verify that the response has the expected WWW-Authenticate header
|
|
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
|
|
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
|
|
}
|
|
return fakeTokenSource, nil
|
|
}
|
|
|
|
transport, err := NewHTTPTransport(handler, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
resp, err := client.Get(authServer.URL)
|
|
if err != nil {
|
|
t.Fatalf("client.Get() failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
if handlerCalls != 1 {
|
|
t.Errorf("handler was called %d times, want 1", handlerCalls)
|
|
}
|
|
|
|
// Second request should reuse the token and not call the handler again.
|
|
resp2, err := client.Get(authServer.URL)
|
|
if err != nil {
|
|
t.Fatalf("second client.Get() failed: %v", err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
|
|
if resp2.StatusCode != http.StatusOK {
|
|
t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK)
|
|
}
|
|
if handlerCalls != 1 {
|
|
t.Errorf("handler should still be called only once, but was %d", handlerCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("request body is cloned", func(t *testing.T) {
|
|
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
|
// Verify that the response has the expected WWW-Authenticate header
|
|
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
|
|
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
|
|
}
|
|
return fakeTokenSource, nil
|
|
}
|
|
|
|
transport, err := NewHTTPTransport(handler, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
resp, err := client.Post(authServer.URL, "application/json", &basicReader{strings.NewReader("{}")})
|
|
if err != nil {
|
|
t.Fatalf("client.Post() failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
})
|
|
|
|
t.Run("handler returns error", func(t *testing.T) {
|
|
handlerErr := errors.New("user rejected auth")
|
|
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
|
return nil, handlerErr
|
|
}
|
|
|
|
transport, err := NewHTTPTransport(handler, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
_, err = client.Get(authServer.URL)
|
|
if !errors.Is(err, handlerErr) {
|
|
t.Errorf("client.Get() returned error %v, want %v", err, handlerErr)
|
|
}
|
|
})
|
|
}
|