Ctrl K
ComunidadGuías…

internal/mcpgateway/oauth_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/modelcontextprotocol/go-sdk/oauthex"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/enterpilot/gomodel/internal/encryption/encryptiontest"
	"github.com/enterpilot/gomodel/internal/storage/sqlx/sqlxtest"
)

const testRedirectURI = "http://127.0.0.1:8080/admin/mcp-oauth/callback"

// fakeAuthServer is an OAuth 2.1 authorization server that checks PKCE and
// the resource indicator, issues a short-lived token, and rotates refresh
// tokens.
type fakeAuthServer struct {
	*httptest.Server
	registration bool
	resource     string

	mu           sync.Mutex
	challenge    string
	refreshToken string
	accessToken  string
	refreshes    int
}

func newFakeAuthServer(t *testing.T, registration bool) *fakeAuthServer {
	t.Helper()
	as := &fakeAuthServer{registration: registration}
	as.Server = httptest.NewServer(http.HandlerFunc(as.serve))
	t.Cleanup(as.Close)
	return as
}

func writeTestJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

func (as *fakeAuthServer) serve(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/.well-known/oauth-authorization-server":
		meta := map[string]any{
			"issuer":                           as.URL,
			"authorization_endpoint":           as.URL + "/authorize",
			"token_endpoint":                   as.URL + "/token",
			"code_challenge_methods_supported": []string{"S256"},
			"scopes_supported":                 []string{"mcp:read", "offline_access"},
			"response_types_supported":         []string{"code"},
		}
		if as.registration {
			meta["registration_endpoint"] = as.URL + "/register"
		}
		writeTestJSON(w, http.StatusOK, meta)
	case "/register":
		var meta map[string]any
		_ = json.NewDecoder(r.Body).Decode(&meta)
		writeTestJSON(w, http.StatusCreated, map[string]any{
			"client_id":                  "dcr-client",
			"redirect_uris":              meta["redirect_uris"],
			"token_endpoint_auth_method": "none",
		})
	case "/token":
		as.token(w, r)
	default:
		http.NotFound(w, r)
	}
}

func (as *fakeAuthServer) token(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		writeTestJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"})
		return
	}
	as.mu.Lock()
	defer as.mu.Unlock()
	if r.PostForm.Get("resource") != as.resource {
		writeTestJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_target"})
		return
	}
	switch r.PostForm.Get("grant_type") {
	case "authorization_code":
		sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
		if r.PostForm.Get("code") != "code-1" || base64.RawURLEncoding.EncodeToString(sum[:]) != as.challenge {
			writeTestJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant"})
			return
		}
		// Expires inside oauth2's expiry delta, so the first use refreshes.
		as.accessToken, as.refreshToken = "access-1", "refresh-1"
		writeTestJSON(w, http.StatusOK, map[string]any{
			"access_token": "access-1", "refresh_token": "refresh-1", "token_type": "Bearer", "expires_in": 1, "scope": "mcp:read offline_access",
		})
	case "refresh_token":
		if r.PostForm.Get("refresh_token") != as.refreshToken {
			writeTestJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "refresh token was rotated"})
			return
		}
		as.refreshes++
		as.accessToken = "access-2"
		as.refreshToken = "refresh-2"
		writeTestJSON(w, http.StatusOK, map[string]any{
			"access_token": "access-2", "refresh_token": "refresh-2", "token_type": "Bearer", "expires_in": 3600,
		})
	default:
		writeTestJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported_grant_type"})
	}
}

func (as *fakeAuthServer) validAccessToken(token string) bool {
	as.mu.Lock()
	defer as.mu.Unlock()
	return as.accessToken != "" && token == as.accessToken
}

// newProtectedUpstream serves an MCP server at /mcp that answers 401 with a
// resource_metadata challenge unless the request carries a token as issued.
func newProtectedUpstream(t *testing.T, as *fakeAuthServer) string {
	t.Helper()
	server := mcp.NewServer(&mcp.Implementation{Name: "protected", Version: "test"}, nil)
	addEchoTool("echo")(server)
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
	var ts *httptest.Server
	ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/.well-known/oauth-protected-resource/mcp" {
			writeTestJSON(w, http.StatusOK, map[string]any{
				"resource":              ts.URL + "/mcp",
				"authorization_servers": []string{as.URL},
				"scopes_supported":      []string{"mcp:read"},
			})
			return
		}
		token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
		if !as.validAccessToken(token) {
			w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+ts.URL+`/.well-known/oauth-protected-resource/mcp", scope="mcp:read"`)
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		handler.ServeHTTP(w, r)
	}))
	t.Cleanup(ts.Close)
	as.resource = ts.URL + "/mcp"
	return ts.URL + "/mcp"
}

func newOAuthTestService(t *testing.T, serverURL string) (*Service, *oauthStore) {
	t.Helper()
	store, err := NewSQLStore(context.Background(), sqlxtest.NewSQLite(t))
	require.NoError(t, err)
	box, _ := encryptiontest.NewBox(t)
	oauth := newOAuthStore(store, box)
	service, _ := newTestServiceWithOptions(t, Options{
		ConfigServers: map[string]ServerSpec{"notion": testSpec("notion", serverURL, nil)},
		Store:         store,
		oauthStore:    oauth,
	})
	return service, oauth
}

func serverView(t *testing.T, service *Service, name string) ServerView {
	t.Helper()
	for _, view := range service.Views() {
		if view.Spec.Name == name {
			return view
		}
	}
	require.FailNow(t, "server not found", name)
	return ServerView{}
}

func waitForStatus(t *testing.T, service *Service, name string, status ServerStatus) ServerView {
	t.Helper()
	var view ServerView
	require.Eventually(t, func() bool {
		view = serverView(t, service, name)
		return view.Status == status
	}, 10*time.Second, 20*time.Millisecond, "server %s did not become %s", name, status)
	return view
}

// authorize plays the browser and authorization server: it records the PKCE
// challenge and returns the callback the server would redirect to.
func authorize(t *testing.T, as *fakeAuthServer, authURL string, binding string) OAuthCallback {
	t.Helper()
	parsed, err := url.Parse(authURL)
	require.NoError(t, err)
	query := parsed.Query()
	as.mu.Lock()
	as.challenge = query.Get("code_challenge")
	as.mu.Unlock()
	return OAuthCallback{State: query.Get("state"), Code: "code-1", Binding: binding}
}

func TestOAuthSignInFlow(t *testing.T) {
	as := newFakeAuthServer(t, true)
	serverURL := newProtectedUpstream(t, as)
	service, store := newOAuthTestService(t, serverURL)
	ctx := context.Background()
	var notified atomic.Int32
	service.SetChangeNotifier(func(context.Context) { notified.Add(1) })

	before := serverView(t, service, "notion")
	assert.Equal(t, StatusDegraded, before.Status)
	assert.True(t, before.AuthRequired, "a 401 handshake marks the server as needing sign-in")
	assert.Nil(t, before.OAuth)

	start, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI})
	require.NoError(t, err)
	require.NotEmpty(t, start.Binding)
	authURL, err := url.Parse(start.AuthorizationURL)
	require.NoError(t, err)
	query := authURL.Query()
	assert.Equal(t, as.URL+"/authorize", authURL.Scheme+"://"+authURL.Host+authURL.Path)
	assert.Equal(t, "dcr-client", query.Get("client_id"), "the client came from dynamic registration")
	assert.Equal(t, "S256", query.Get("code_challenge_method"))
	assert.Equal(t, serverURL, query.Get("resource"))
	assert.Equal(t, testRedirectURI, query.Get("redirect_uri"))
	assert.Equal(t, "mcp:read offline_access", query.Get("scope"), "challenge scope plus offline_access")
	assert.NotEmpty(t, query.Get("state"))

	name, err := service.CompleteOAuth(ctx, authorize(t, as, start.AuthorizationURL, start.Binding))
	require.NoError(t, err)
	assert.Equal(t, "notion", name)
	assert.Equal(t, int32(1), notified.Load(), "a sign-in is announced to other gateway instances")

	view := waitForStatus(t, service, "notion", StatusConnected)
	assert.False(t, view.AuthRequired)
	assert.Equal(t, 1, view.ToolCount)
	require.NotNil(t, view.OAuth)
	assert.Equal(t, OAuthStatusSignedIn, view.OAuth.Status)
	assert.Equal(t, as.URL, view.OAuth.Issuer)
	assert.Equal(t, []string{"mcp:read", "offline_access"}, view.OAuth.Scopes)

	// The short-lived token was refreshed on first use, with the resource
	// indicator, and the rotated refresh token was persisted.
	as.mu.Lock()
	refreshes := as.refreshes
	as.mu.Unlock()
	assert.GreaterOrEqual(t, refreshes, 1)
	stored, err := store.get(ctx, "notion")
	require.NoError(t, err)
	assert.Equal(t, "refresh-2", stored.RefreshToken)
	assert.Equal(t, "access-2", stored.AccessToken)

	result, err := service.manager.CallTool(ctx, "notion", "echo", json.RawMessage(`{"x":1}`))
	require.NoError(t, err)
	assert.False(t, result.IsError)

	require.NoError(t, service.SignOutOAuth(ctx, "notion"))
	assert.Equal(t, int32(2), notified.Load(), "a sign-out is announced to other gateway instances")
	_, err = store.get(ctx, "notion")
	require.ErrorIs(t, err, ErrNotFound)
	view = waitForStatus(t, service, "notion", StatusDegraded)
	assert.Nil(t, view.OAuth)
	assert.True(t, view.AuthRequired)
}

func TestOAuthCallbackRejections(t *testing.T) {
	as := newFakeAuthServer(t, true)
	serverURL := newProtectedUpstream(t, as)
	service, store := newOAuthTestService(t, serverURL)
	ctx := context.Background()

	tests := []struct {
		name    string
		mutate  func(*OAuthCallback)
		wantErr string
	}{
		{name: "other browser", mutate: func(cb *OAuthCallback) { cb.Binding = "other" }, wantErr: "different browser"},
		{name: "unexpected issuer", mutate: func(cb *OAuthCallback) { cb.Issuer = "https://evil.example" }, wantErr: "unexpected issuer"},
		{name: "denied", mutate: func(cb *OAuthCallback) { cb.Code = ""; cb.Error = "access_denied" }, wantErr: "access_denied"},
		{name: "wrong code", mutate: func(cb *OAuthCallback) { cb.Code = "forged" }, wantErr: "invalid_grant"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			start, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI})
			require.NoError(t, err)
			callback := authorize(t, as, start.AuthorizationURL, start.Binding)
			tt.mutate(&callback)

			name, err := service.CompleteOAuth(ctx, callback)
			require.ErrorContains(t, err, tt.wantErr)
			assert.Equal(t, "notion", name)
			_, err = store.get(ctx, "notion")
			require.ErrorIs(t, err, ErrNotFound, "nothing is stored")
			view := serverView(t, service, "notion")
			require.NotNil(t, view.OAuth)
			assert.Equal(t, OAuthStatusError, view.OAuth.Status)
			assert.Contains(t, view.OAuth.LastError, tt.wantErr)

			// The state is single-use.
			_, err = service.CompleteOAuth(ctx, callback)
			require.ErrorIs(t, err, ErrOAuthFlowUnknown)
		})
	}

	_, err := service.CompleteOAuth(ctx, OAuthCallback{State: "unknown", Code: "code-1"})
	require.ErrorIs(t, err, ErrOAuthFlowUnknown)
}

func TestOAuthStartWithoutDynamicRegistration(t *testing.T) {
	as := newFakeAuthServer(t, false)
	serverURL := newProtectedUpstream(t, as)
	service, _ := newOAuthTestService(t, serverURL)
	ctx := context.Background()

	_, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI})
	require.ErrorIs(t, err, ErrOAuthClientRequired)

	start, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI, ClientID: "pre-registered"})
	require.NoError(t, err)
	authURL, err := url.Parse(start.AuthorizationURL)
	require.NoError(t, err)
	assert.Equal(t, "pre-registered", authURL.Query().Get("client_id"))

	_, err = service.StartOAuth(ctx, "missing", OAuthStartRequest{RedirectURI: testRedirectURI})
	require.ErrorIs(t, err, ErrNotFound)
	_, err = service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: "https://evil.example/steal"})
	require.ErrorIs(t, err, ErrOAuthInvalidRequest)
}

func TestValidateOAuthRedirectURI(t *testing.T) {
	tests := []struct {
		uri   string
		valid bool
	}{
		{uri: "https://gomodel.example.com/admin/mcp-oauth/callback", valid: true},
		{uri: "http://localhost:8080/admin/mcp-oauth/callback", valid: true},
		{uri: "https://example.com/gomodel/admin/mcp-oauth/callback", valid: true},
		{uri: "/admin/mcp-oauth/callback"},
		{uri: "ftp://example.com/admin/mcp-oauth/callback"},
		{uri: "https://example.com/admin/mcp-oauth/callback?next=x"},
		{uri: "https://example.com/admin/mcp-oauth/callback#x"},
		{uri: "https://user:pass@example.com/admin/mcp-oauth/callback"},
		{uri: "https://example.com/other"},
	}
	for _, tt := range tests {
		t.Run(tt.uri, func(t *testing.T) {
			err := ValidateOAuthRedirectURI(tt.uri)
			if tt.valid {
				require.NoError(t, err)
				return
			}
			require.ErrorIs(t, err, ErrOAuthInvalidRequest)
		})
	}
}

func TestValidateAuthorizationIssuer(t *testing.T) {
	tests := []struct {
		name      string
		iss       string
		supported bool
		wantErr   bool
	}{
		{name: "advertised and matching", iss: "https://as.example", supported: true},
		{name: "advertised but absent", supported: true, wantErr: true},
		{name: "advertised and different", iss: "https://other.example", supported: true, wantErr: true},
		{name: "not advertised and absent"},
		{name: "not advertised but matching", iss: "https://as.example"},
		{name: "not advertised and different", iss: "https://other.example", wantErr: true},
		{name: "no normalization", iss: "https://as.example/", wantErr: true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateAuthorizationIssuer(tt.iss, "https://as.example", tt.supported)
			if tt.wantErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
		})
	}
}

func TestProtectedResourceMetadataURLs(t *testing.T) {
	tests := []struct {
		name        string
		metadataURL string
		serverURL   string
		want        []prmCandidate
	}{
		{
			name:      "path endpoint",
			serverURL: "https://mcp.example.com/public/mcp",
			want: []prmCandidate{
				{url: "https://mcp.example.com/.well-known/oauth-protected-resource/public/mcp", resource: "https://mcp.example.com/public/mcp"},
				{url: "https://mcp.example.com/.well-known/oauth-protected-resource", resource: "https://mcp.example.com"},
			},
		},
		{
			name:      "escaped separator",
			serverURL: "https://mcp.example.com/a%2Fb/mcp",
			want: []prmCandidate{
				{url: "https://mcp.example.com/.well-known/oauth-protected-resource/a%2Fb/mcp", resource: "https://mcp.example.com/a%2Fb/mcp"},
				{url: "https://mcp.example.com/.well-known/oauth-protected-resource", resource: "https://mcp.example.com"},
			},
		},
		{
			name:        "challenge first",
			metadataURL: "https://mcp.example.com/prm",
			serverURL:   "https://mcp.example.com",
			want: []prmCandidate{
				{url: "https://mcp.example.com/prm", resource: "https://mcp.example.com"},
				{url: "https://mcp.example.com/.well-known/oauth-protected-resource", resource: "https://mcp.example.com"},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.want, protectedResourceMetadataURLs(tt.metadataURL, tt.serverURL))
		})
	}
}

func TestBearerRoundTripperStaysOnServerOrigin(t *testing.T) {
	var seen []string
	base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
		seen = append(seen, req.Header.Get("Authorization"))
		return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: req}, nil
	})
	record := testOAuthRecord()
	transport := &bearerRoundTripper{
		base:   base,
		source: newOAuthSource("notion", record, nil, nil),
		origin: "https://mcp.example.com",
	}
	for _, target := range []string{"https://mcp.example.com/mcp", "https://elsewhere.example.com/mcp"} {
		req, err := http.NewRequest(http.MethodGet, target, nil)
		require.NoError(t, err)
		req.Header.Set("Authorization", "Bearer static")
		resp, err := transport.RoundTrip(req)
		require.NoError(t, err)
		_ = resp.Body.Close()
	}
	assert.Equal(t, []string{"Bearer access-secret", "Bearer static"}, seen)
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }

func TestOAuthTokenReplacesStaticAuthorizationHeader(t *testing.T) {
	got := make(chan string, 1)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got <- r.Header.Get("Authorization")
		w.WriteHeader(http.StatusNoContent)
	}))
	t.Cleanup(server.Close)

	record := testOAuthRecord()
	record.ServerURL = server.URL
	spec := testSpec("notion", server.URL, func(spec *ServerSpec) {
		spec.Headers = map[string]string{"Authorization": "Bearer static", "X-Team": "alpha"}
		spec.oauth = newOAuthSource("notion", record, nil, nil)
	})
	u := newUpstream(spec, server.Client())
	client := u.dialClient(&connectProbe{method: http.MethodPost}, context.Background())

	resp, err := client.Get(server.URL)
	require.NoError(t, err)
	_ = resp.Body.Close()
	assert.Equal(t, "Bearer access-secret", <-got)
}

func TestOAuthAttachDropsKeptSourceWhenURLChanged(t *testing.T) {
	record := testOAuthRecord()
	manager := newOAuthManager(nil, nil)
	current := newOAuthSource("notion", record, nil, nil)
	manager.sources["notion"] = current

	specs := []ServerSpec{testSpec("notion", record.ServerURL, nil)}
	manager.attach(specs, nil, true, nil)
	assert.Same(t, current, specs[0].oauth, "an unreadable record keeps the current tokens")

	specs = []ServerSpec{testSpec("notion", "https://other.example.com/mcp", nil)}
	manager.attach(specs, nil, true, nil)
	assert.Nil(t, specs[0].oauth, "tokens never follow a server to a new URL")
}

func TestRegistrationAuthMethod(t *testing.T) {
	tests := []struct {
		name      string
		supported []string
		want      string
	}{
		{name: "not listed", want: "none"},
		{name: "public clients allowed", supported: []string{"client_secret_basic", "none"}, want: "none"},
		{name: "basic only", supported: []string{"client_secret_basic"}, want: "client_secret_basic"},
		{name: "post only", supported: []string{"client_secret_post"}, want: "client_secret_post"},
		{name: "unknown methods", supported: []string{"private_key_jwt"}, want: "none"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.want, registrationAuthMethod(tt.supported))
		})
	}
}

func TestResolveOAuthClientComparesIssuersExactly(t *testing.T) {
	stored := testOAuthRecord()
	stored.Issuer = "https://as.example/"
	stored.RedirectURI = testRedirectURI
	req := OAuthStartRequest{RedirectURI: testRedirectURI}

	asm := &oauthex.AuthServerMeta{Issuer: "https://as.example/"}
	client, err := resolveOAuthClient(context.Background(), nil, asm, req, &stored)
	require.NoError(t, err)
	assert.Equal(t, stored.ClientID, client.id, "the same issuer reuses the stored client")

	asm = &oauthex.AuthServerMeta{Issuer: "https://as.example"}
	_, err = resolveOAuthClient(context.Background(), nil, asm, req, &stored)
	require.ErrorIs(t, err, ErrOAuthClientRequired, "another issuer string never gets the stored client")
}

func TestOAuthSignOutDropsKeptSource(t *testing.T) {
	record := testOAuthRecord()
	manager := newOAuthManager(nil, nil)
	manager.sources["notion"] = newOAuthSource("notion", record, nil, nil)

	manager.signedOut("notion")
	specs := []ServerSpec{testSpec("notion", record.ServerURL, nil)}
	manager.attach(specs, nil, true, nil)
	assert.Nil(t, specs[0].oauth, "a reload that cannot read the store keeps no signed-out tokens")
}

func TestOAuthFailureFollowsServerURL(t *testing.T) {
	manager := newOAuthManager(nil, nil)
	manager.fail("notion", "https://old.example.com/mcp", assert.AnError)

	view := manager.view(testSpec("notion", "https://old.example.com/mcp", nil))
	require.NotNil(t, view)
	assert.Equal(t, OAuthStatusError, view.Status)
	assert.Nil(t, manager.view(testSpec("notion", "https://new.example.com/mcp", nil)),
		"a failure from the previous URL is not shown")
}

func TestOAuthSecureURL(t *testing.T) {
	tests := []struct {
		url  string
		want bool
	}{
		{url: "https://mcp.example.com/mcp", want: true},
		{url: "http://localhost:8080/mcp", want: true},
		{url: "http://LOCALHOST:8080/mcp", want: true},
		{url: "http://127.0.0.1:8080/mcp", want: true},
		{url: "http://[::1]:8080/mcp", want: true},
		{url: "http://mcp.example.com/mcp"},
		{url: "http://192.168.1.10/mcp"},
		{url: "ftp://mcp.example.com/mcp"},
		{url: "://bad"},
	}
	for _, tt := range tests {
		t.Run(tt.url, func(t *testing.T) {
			assert.Equal(t, tt.want, oauthSecureURL(tt.url))
		})
	}
}

func TestOAuthAttachSkipsPlainHTTPServers(t *testing.T) {
	record := testOAuthRecord()
	record.ServerURL = "http://mcp.example.com/mcp"
	manager := newOAuthManager(nil, nil)

	specs := []ServerSpec{testSpec("notion", record.ServerURL, nil)}
	manager.attach(specs, map[string]oauthRecord{"notion": record}, false, nil)
	assert.Nil(t, specs[0].oauth, "tokens never go to a non-loopback http server")
}

func TestConnectProbeFollowsRedirectToUnauthorized(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/mcp" {
			http.Redirect(w, r, "/mcp/", http.StatusTemporaryRedirect)
			return
		}
		http.Error(w, "unauthorized", http.StatusUnauthorized)
	}))
	t.Cleanup(server.Close)

	u := newUpstream(testSpec("notion", server.URL+"/mcp", nil), server.Client())
	probe := &connectProbe{method: http.MethodPost}
	client := u.dialClient(probe, context.Background())
	resp, err := client.Post(server.URL+"/mcp", "application/json", strings.NewReader("{}"))
	require.NoError(t, err)
	_ = resp.Body.Close()
	assert.True(t, probe.unauthorized(), "the 401 after a redirect counts, not the redirect")
}

func TestOAuthSignOutCancelsPendingSignIn(t *testing.T) {
	as := newFakeAuthServer(t, true)
	serverURL := newProtectedUpstream(t, as)
	service, store := newOAuthTestService(t, serverURL)
	ctx := context.Background()

	pending, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI})
	require.NoError(t, err)
	callback := authorize(t, as, pending.AuthorizationURL, pending.Binding)
	require.NoError(t, service.SignOutOAuth(ctx, "notion"))
	_, err = service.CompleteOAuth(ctx, callback)
	require.ErrorIs(t, err, ErrOAuthFlowUnknown, "a sign-out drops pending sign-ins")

	// A flow taken before the sign-out, whose exchange finishes after it.
	running, err := service.StartOAuth(ctx, "notion", OAuthStartRequest{RedirectURI: testRedirectURI})
	require.NoError(t, err)
	flow, ok := service.oauth.takeFlow(authorize(t, as, running.AuthorizationURL, running.Binding).State)
	require.True(t, ok)
	service.oauth.signedOut("notion")
	err = service.completeOAuth(ctx, flow, OAuthCallback{Code: "code-1", Binding: running.Binding})
	require.ErrorContains(t, err, "signed out, removed, or changed")
	_, err = store.get(ctx, "notion")
	require.ErrorIs(t, err, ErrNotFound, "the late callback does not sign the server back in")

	// A sign-out while discovery ran, before the flow was registered.
	generation := service.oauth.generation("notion")
	service.oauth.signedOut("notion")
	spec, ok := service.manager.spec("notion")
	require.True(t, ok)
	early, err := service.startOAuth(ctx, spec, OAuthStartRequest{RedirectURI: testRedirectURI}, generation)
	require.NoError(t, err)
	_, err = service.CompleteOAuth(ctx, authorize(t, as, early.AuthorizationURL, early.Binding))
	require.ErrorContains(t, err, "signed out, removed, or changed")
	_, err = store.get(ctx, "notion")
	require.ErrorIs(t, err, ErrNotFound)
}

func TestOAuthAttachDoesNotHoldManagerLockOnBusySource(t *testing.T) {
	record := testOAuthRecord()
	manager := newOAuthManager(nil, nil)
	busy := newOAuthSource("notion", record, nil, nil)
	manager.sources["notion"] = busy

	busy.mu.Lock() // a token refresh in flight
	attached := make(chan struct{})
	go func() {
		manager.attach([]ServerSpec{testSpec("notion", record.ServerURL, nil)}, map[string]oauthRecord{"notion": record}, false, nil)
		close(attached)
	}()
	answered := make(chan struct{})
	go func() {
		manager.generation("notion")
		close(answered)
	}()
	select {
	case <-answered:
	case <-time.After(5 * time.Second):
		assert.Fail(t, "the manager lock was held while waiting for a busy source")
	}
	busy.mu.Unlock()
	<-attached
}

func TestOAuthAttachIgnoresRecordsReadBeforeSignOut(t *testing.T) {
	record := testOAuthRecord()
	manager := newOAuthManager(nil, nil)

	// A reload read the record, then a sign-out landed before it attached.
	generations := manager.generationsSnapshot()
	manager.signedOut("notion")
	specs := []ServerSpec{testSpec("notion", record.ServerURL, nil)}
	manager.attach(specs, map[string]oauthRecord{"notion": record}, false, generations)
	assert.Nil(t, specs[0].oauth, "a stale snapshot never brings signed-out tokens back")

	// The next reload reads after the sign-out and attaches normally.
	specs = []ServerSpec{testSpec("notion", record.ServerURL, nil)}
	manager.attach(specs, map[string]oauthRecord{"notion": record}, false, manager.generationsSnapshot())
	assert.NotNil(t, specs[0].oauth)
}