From bd6a470d6d5b5578a3dca2f1b6392bd545075970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:52:58 +0200 Subject: [PATCH] Add user:pass Basic auth mode for API-key connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API-key connection transport could present a key as a Bearer token, an x-api-key header, a custom scheme (SSWS/Token), or HTTP Basic with an empty password (Cursor). None of these can carry a real password, which providers such as ClickHouse Cloud (keyId:keySecret) and Langfuse (publicKey:secretKey) require. Add a fourth mode, APIKeyBasicAuthUserPass, that base64-encodes the stored "username:password" credential verbatim into Authorization: Basic. SetBasicAuth cannot express this -- it re-appends a ":" and corrupts the credential. The mode is wired generically through the registry and the create-connector resolver and is mutually exclusive with the other API-key auth modes. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/connector/apikey.go | 38 +++++++++++++++++++ pkg/connector/apikey_test.go | 36 ++++++++++++++++++ pkg/connector/provider/registry.go | 28 +++++++++++--- pkg/connector/provider/registry_test.go | 14 +++++++ pkg/connector/provider/types.go | 9 +++++ .../api/console/v1/connector_resolvers.go | 9 +++-- 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/pkg/connector/apikey.go b/pkg/connector/apikey.go index db31c1cda..db9ada4b0 100644 --- a/pkg/connector/apikey.go +++ b/pkg/connector/apikey.go @@ -16,6 +16,7 @@ package connector import ( "context" + "encoding/base64" "encoding/json" "net/http" @@ -41,6 +42,15 @@ type APIKeyConnection struct { // It is mutually exclusive with Header and is populated from the // provider Registration at connector creation time. BasicAuth bool `json:"basic_auth,omitempty"` + // BasicAuthUserPass, when true, presents the API key as a complete HTTP + // Basic credential (`Authorization: Basic base64()`), with the + // stored key already holding the `username:password` pair — required + // by providers such as ClickHouse Cloud (keyId:keySecret) and + // Langfuse (publicKey:secretKey) whose Basic credential carries a real + // password, which BasicAuth (empty password) cannot express. It is + // mutually exclusive with the other modes and is populated from the + // provider Registration at connector creation time. + BasicAuthUserPass bool `json:"basic_auth_user_pass,omitempty"` // Scheme selects a non-Bearer Authorization scheme: when non-empty // the key is sent as `Authorization: ` instead of // `Authorization: Bearer ` — required by providers such as Okta @@ -72,6 +82,15 @@ func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) { }, nil } + if c.BasicAuthUserPass { + return &http.Client{ + Transport: &basicAuthUserPassTransport{ + credential: c.APIKey, + underlying: underlying, + }, + }, nil + } + if c.Header != "" { return &http.Client{ Transport: &apiKeyHeaderTransport{ @@ -153,6 +172,25 @@ func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error return t.underlying.RoundTrip(req2) } +// basicAuthUserPassTransport presents a complete HTTP Basic credential whose +// `username:password` pair is already encoded in the stored key +// (`Authorization: Basic base64()`). Providers such as +// ClickHouse Cloud (keyId:keySecret) and Langfuse (publicKey:secretKey) +// authenticate with a real password, which basicAuthTransport's empty +// password cannot carry; SetBasicAuth would also re-append a ":" and +// corrupt the credential, so the value is base64-encoded verbatim. +type basicAuthUserPassTransport struct { + credential string + underlying http.RoundTripper +} + +func (t *basicAuthUserPassTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(t.credential))) + + 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 index 408dbc206..318b971c4 100644 --- a/pkg/connector/apikey_test.go +++ b/pkg/connector/apikey_test.go @@ -76,6 +76,42 @@ func TestAPIKeyConnection_Client_BasicAuth(t *testing.T) { assert.Equal(t, "key_secret", transport.username) } +func TestBasicAuthUserPassTransport_RoundTrip(t *testing.T) { + t.Parallel() + + rec := &recordingRoundTripper{} + transport := &basicAuthUserPassTransport{credential: "key_id:key_secret", underlying: rec} + + req := httptest.NewRequest(http.MethodGet, "https://api.clickhouse.cloud/v1/organizations", nil) + _, err := transport.RoundTrip(req) + require.NoError(t, err) + + require.NotNil(t, rec.lastRequest) + // The stored credential already carries username:password, so it is + // base64-encoded verbatim and the password survives the round-trip. + user, pass, ok := rec.lastRequest.BasicAuth() + require.True(t, ok, "expected a Basic auth header") + assert.Equal(t, "key_id", user) + assert.Equal(t, "key_secret", pass) + + // 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_BasicAuthUserPass(t *testing.T) { + t.Parallel() + + conn := &APIKeyConnection{APIKey: "key_id:key_secret", BasicAuthUserPass: true} + + client, err := conn.Client(context.Background()) + require.NoError(t, err) + + transport, ok := client.Transport.(*basicAuthUserPassTransport) + require.Truef(t, ok, "expected *basicAuthUserPassTransport, got %T", client.Transport) + assert.Equal(t, "key_id:key_secret", transport.credential) +} + func TestAPIKeyConnection_Client_Header(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go index 6ee732ae3..837131666 100644 --- a/pkg/connector/provider/registry.go +++ b/pkg/connector/provider/registry.go @@ -71,16 +71,21 @@ func (r *Registry) Register(reg *Registration) error { return fmt.Errorf("cannot register connector provider %q: missing DisplayName", reg.Provider) } - // APIKeyBasicAuth, APIKeyHeader, and APIKeyAuthScheme select different - // presentations of the same key; setting more than one is a programmer - // error with a silent winner (Client checks BasicAuth, then Header, - // then Scheme). Reject it at startup. + // APIKeyBasicAuth, APIKeyBasicAuthUserPass, APIKeyHeader, and + // APIKeyAuthScheme select different presentations of the same key; + // setting more than one is a programmer error with a silent winner + // (Client checks BasicAuth, then BasicAuthUserPass, then Header, then + // Scheme). Reject it at startup. apiKeyModes := 0 if reg.APIKeyBasicAuth { apiKeyModes++ } + if reg.APIKeyBasicAuthUserPass { + apiKeyModes++ + } + if reg.APIKeyHeader != "" { apiKeyModes++ } @@ -90,7 +95,7 @@ func (r *Registry) Register(reg *Registration) error { } if apiKeyModes > 1 { - return fmt.Errorf("cannot register connector provider %q: APIKeyBasicAuth, APIKeyHeader, and APIKeyAuthScheme are mutually exclusive", reg.Provider) + return fmt.Errorf("cannot register connector provider %q: APIKeyBasicAuth, APIKeyBasicAuthUserPass, APIKeyHeader, and APIKeyAuthScheme are mutually exclusive", reg.Provider) } // BuildTokenURLForDomain and BuildTokenURLForSite both build the token @@ -206,6 +211,19 @@ func (r *Registry) APIKeyAuthScheme(p coredata.ConnectorProvider) string { return "" } +// APIKeyUsesBasicAuthUserPass reports whether an API-key connection for the +// given provider must present its key as a complete HTTP Basic credential +// (`username:password` already encoded in the key, base64'd verbatim) +// instead of a Bearer token. Returns false for unknown providers and for +// providers that use the default Bearer scheme. +func (r *Registry) APIKeyUsesBasicAuthUserPass(p coredata.ConnectorProvider) bool { + if reg, ok := r.Get(p); ok { + return reg.APIKeyBasicAuthUserPass + } + + 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 aec7b34bf..c55fd00f1 100644 --- a/pkg/connector/provider/registry_test.go +++ b/pkg/connector/provider/registry_test.go @@ -109,6 +109,20 @@ func TestRegistry_Register(t *testing.T) { assert.Contains(t, err.Error(), "mutually exclusive") }) + t.Run("APIKeyBasicAuthUserPass and APIKeyHeader mutually exclusive", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + APIKeyBasicAuthUserPass: true, + APIKeyHeader: "x-api-key", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) + t.Run("BuildTokenURLForDomain and BuildTokenURLForSite mutually exclusive", func(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index 6cc070eec..1820bde6c 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -103,6 +103,15 @@ type Registration struct { // Consumed when the create-connector resolver builds the // APIKeyConnection. APIKeyAuthScheme string + // APIKeyBasicAuthUserPass, when true, presents the API key as a complete + // HTTP Basic credential whose `username:password` pair is already + // encoded in the key (base64 of the verbatim string) — required by + // providers such as ClickHouse Cloud (keyId:keySecret) and Langfuse + // (publicKey:secretKey) whose Basic credential carries a real + // password, unlike APIKeyBasicAuth's empty-password form. Mutually + // exclusive with the other API-key auth modes. Consumed when the + // create-connector resolver builds the APIKeyConnection. + APIKeyBasicAuthUserPass bool // BuildProbeURL derives a per-connector probe URL when the API host or // path depends on connector settings (e.g. a customer subdomain or diff --git a/pkg/server/api/console/v1/connector_resolvers.go b/pkg/server/api/console/v1/connector_resolvers.go index 44af0f2eb..9004c96b6 100644 --- a/pkg/server/api/console/v1/connector_resolvers.go +++ b/pkg/server/api/console/v1/connector_resolvers.go @@ -41,10 +41,11 @@ 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), - BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(input.Provider), - Scheme: r.providerRegistry.APIKeyAuthScheme(input.Provider), + APIKey: input.APIKey, + Header: r.providerRegistry.APIKeyHeader(input.Provider), + BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(input.Provider), + BasicAuthUserPass: r.providerRegistry.APIKeyUsesBasicAuthUserPass(input.Provider), + Scheme: r.providerRegistry.APIKeyAuthScheme(input.Provider), }, }