diff --git a/pkg/connector/apikey.go b/pkg/connector/apikey.go index 3e4784259..b20ee45bd 100644 --- a/pkg/connector/apikey.go +++ b/pkg/connector/apikey.go @@ -34,6 +34,13 @@ type APIKeyConnection struct { // It is populated from the provider Registration at connector // creation time. Header string `json:"header,omitempty"` + // BasicAuth, when true, presents the API key as the username of an + // HTTP Basic credential with an empty password (`Authorization: + // Basic base64(:)`) — required by providers such as Cursor + // whose Admin API documents Basic auth and rejects Bearer tokens. + // It is mutually exclusive with Header and is populated from the + // provider Registration at connector creation time. + BasicAuth bool `json:"basic_auth,omitempty"` } var _ Connection = (*APIKeyConnection)(nil) @@ -49,6 +56,15 @@ func (c *APIKeyConnection) Scopes() []string { func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) { underlying := httpclient.DefaultPooledTransport(httpclient.WithSSRFProtection()) + if c.BasicAuth { + return &http.Client{ + Transport: &basicAuthTransport{ + username: c.APIKey, + underlying: underlying, + }, + }, nil + } + if c.Header != "" { return &http.Client{ Transport: &apiKeyHeaderTransport{ @@ -86,6 +102,22 @@ func (t *apiKeyHeaderTransport) RoundTrip(req *http.Request) (*http.Response, er return t.underlying.RoundTrip(req2) } +// basicAuthTransport presents the API key as the username of an HTTP +// Basic credential with an empty password. Providers such as Cursor +// document `-u :` Basic auth for their Admin API and reject Bearer +// tokens, so neither oauth2Transport nor apiKeyHeaderTransport fits. +type basicAuthTransport struct { + username string + underlying http.RoundTripper +} + +func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.SetBasicAuth(t.username, "") + + return t.underlying.RoundTrip(req2) +} + func (c APIKeyConnection) MarshalJSON() ([]byte, error) { type Alias APIKeyConnection diff --git a/pkg/connector/apikey_test.go b/pkg/connector/apikey_test.go new file mode 100644 index 000000000..b2968cb36 --- /dev/null +++ b/pkg/connector/apikey_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingRoundTripper captures the last request it sees and returns a +// canned 200 response without touching the network, so transport +// behaviour can be asserted without tripping SSRF protection. +type recordingRoundTripper struct { + lastRequest *http.Request +} + +func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.lastRequest = req + + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Header: make(http.Header), + Request: req, + }, nil +} + +func TestBasicAuthTransport_RoundTrip(t *testing.T) { + t.Parallel() + + rec := &recordingRoundTripper{} + transport := &basicAuthTransport{username: "key_secret", underlying: rec} + + req := httptest.NewRequest(http.MethodGet, "https://api.cursor.com/teams/members", nil) + _, err := transport.RoundTrip(req) + require.NoError(t, err) + + require.NotNil(t, rec.lastRequest) + user, pass, ok := rec.lastRequest.BasicAuth() + require.True(t, ok, "expected a Basic auth header") + assert.Equal(t, "key_secret", user) + assert.Empty(t, pass, "password must be empty") + + // The original request must be left untouched (RoundTrip clones it). + _, _, originalHasAuth := req.BasicAuth() + assert.False(t, originalHasAuth, "original request must not be mutated") +} + +func TestAPIKeyConnection_Client_BasicAuth(t *testing.T) { + t.Parallel() + + conn := &APIKeyConnection{APIKey: "key_secret", BasicAuth: true} + + client, err := conn.Client(context.Background()) + require.NoError(t, err) + + transport, ok := client.Transport.(*basicAuthTransport) + require.Truef(t, ok, "expected *basicAuthTransport, got %T", client.Transport) + assert.Equal(t, "key_secret", transport.username) +} + +func TestAPIKeyConnection_Client_Header(t *testing.T) { + t.Parallel() + + conn := &APIKeyConnection{APIKey: "sk-ant-admin", Header: "x-api-key"} + + client, err := conn.Client(context.Background()) + require.NoError(t, err) + + transport, ok := client.Transport.(*apiKeyHeaderTransport) + require.Truef(t, ok, "expected *apiKeyHeaderTransport, got %T", client.Transport) + assert.Equal(t, "x-api-key", transport.header) + assert.Equal(t, "sk-ant-admin", transport.value) +} + +func TestAPIKeyConnection_Client_BearerDefault(t *testing.T) { + t.Parallel() + + conn := &APIKeyConnection{APIKey: "token"} + + client, err := conn.Client(context.Background()) + require.NoError(t, err) + + transport, ok := client.Transport.(*oauth2Transport) + require.Truef(t, ok, "expected *oauth2Transport, got %T", client.Transport) + assert.Equal(t, "token", transport.token) +} diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go index 3a1a75ed8..85e2039cb 100644 --- a/pkg/connector/provider/registry.go +++ b/pkg/connector/provider/registry.go @@ -71,6 +71,13 @@ func (r *Registry) Register(reg *Registration) error { return fmt.Errorf("cannot register connector provider %q: missing DisplayName", reg.Provider) } + // APIKeyBasicAuth and APIKeyHeader select different presentations of + // the same key; setting both is a programmer error with a silent + // winner (Client checks BasicAuth first). Reject it at startup. + if reg.APIKeyBasicAuth && reg.APIKeyHeader != "" { + return fmt.Errorf("cannot register connector provider %q: APIKeyBasicAuth and APIKeyHeader are mutually exclusive", reg.Provider) + } + r.mu.Lock() defer r.mu.Unlock() @@ -132,6 +139,18 @@ func (r *Registry) APIKeyHeader(p coredata.ConnectorProvider) string { return "" } +// APIKeyUsesBasicAuth reports whether an API-key connection for the +// given provider must present its key as an HTTP Basic auth username +// (empty password) instead of a Bearer token. Returns false for unknown +// providers and for providers that use the default Bearer scheme. +func (r *Registry) APIKeyUsesBasicAuth(p coredata.ConnectorProvider) bool { + if reg, ok := r.Get(p); ok { + return reg.APIKeyBasicAuth + } + + return false +} + // ProviderOAuth2Scopes returns the OAuth2 scopes the access review // driver for the given provider needs to list user accounts. Returns // nil for providers that do not need any scopes (Notion, Intercom) diff --git a/pkg/connector/provider/registry_test.go b/pkg/connector/provider/registry_test.go index d8e3fa2e5..c0759da85 100644 --- a/pkg/connector/provider/registry_test.go +++ b/pkg/connector/provider/registry_test.go @@ -81,6 +81,20 @@ func TestRegistry_Register(t *testing.T) { assert.Contains(t, err.Error(), "missing DisplayName") }) + t.Run("APIKeyBasicAuth and APIKeyHeader mutually exclusive", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + APIKeyBasicAuth: true, + APIKeyHeader: "x-api-key", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) + t.Run("duplicate registration", func(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index 0c02b5d21..08f022927 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -63,6 +63,13 @@ type Registration struct { // (Anthropic). It is consumed when the create-connector resolver // builds the APIKeyConnection. APIKeyHeader string + // APIKeyBasicAuth, when true, presents the API key as the username + // of an HTTP Basic credential with an empty password instead of a + // Bearer token — required by providers such as Cursor whose Admin + // API documents `-u :` Basic auth. Mutually exclusive with + // APIKeyHeader. Consumed when the create-connector resolver builds + // the APIKeyConnection. + APIKeyBasicAuth bool // Factory closures — wired by Stages 2 and 3. NewDriver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error) diff --git a/pkg/server/api/console/v1/connector_resolvers.go b/pkg/server/api/console/v1/connector_resolvers.go index 7a164fcc0..b7e7d28c9 100644 --- a/pkg/server/api/console/v1/connector_resolvers.go +++ b/pkg/server/api/console/v1/connector_resolvers.go @@ -41,8 +41,9 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type Provider: input.Provider, Protocol: coredata.ConnectorProtocolAPIKey, Connection: &connector.APIKeyConnection{ - APIKey: input.APIKey, - Header: r.providerRegistry.APIKeyHeader(input.Provider), + APIKey: input.APIKey, + Header: r.providerRegistry.APIKeyHeader(input.Provider), + BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(input.Provider), }, }