diff --git a/pkg/connector/apikey.go b/pkg/connector/apikey.go index b20ee45bd..09eaaef4c 100644 --- a/pkg/connector/apikey.go +++ b/pkg/connector/apikey.go @@ -41,6 +41,13 @@ 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"` + // 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 + // whose API tokens use the `SSWS` scheme. It is mutually exclusive + // with Header and BasicAuth and is populated from the provider + // Registration at connector creation time. + Scheme string `json:"scheme,omitempty"` } var _ Connection = (*APIKeyConnection)(nil) @@ -75,6 +82,16 @@ func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) { }, nil } + if c.Scheme != "" { + return &http.Client{ + Transport: &schemeAuthTransport{ + scheme: c.Scheme, + token: c.APIKey, + underlying: underlying, + }, + }, nil + } + return &http.Client{ Transport: &oauth2Transport{ token: c.APIKey, @@ -84,6 +101,24 @@ func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) { }, nil } +// schemeAuthTransport presents the API key in the Authorization header +// under a non-Bearer scheme (`Authorization: `). +// Providers such as Okta document the `SSWS` scheme for their API tokens +// and reject Bearer, so neither oauth2Transport (which hardcodes Bearer) +// nor apiKeyHeaderTransport (which sets a non-Authorization header) fits. +type schemeAuthTransport struct { + scheme string + token string + underlying http.RoundTripper +} + +func (t *schemeAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", t.scheme+" "+t.token) + + return t.underlying.RoundTrip(req2) +} + // apiKeyHeaderTransport injects the API key into a custom request header // (for example "x-api-key") and, unlike oauth2Transport, never sets // Authorization. Providers such as Anthropic require the key in their diff --git a/pkg/connector/apikey_test.go b/pkg/connector/apikey_test.go index b2968cb36..52c144de2 100644 --- a/pkg/connector/apikey_test.go +++ b/pkg/connector/apikey_test.go @@ -102,3 +102,34 @@ func TestAPIKeyConnection_Client_BearerDefault(t *testing.T) { require.Truef(t, ok, "expected *oauth2Transport, got %T", client.Transport) assert.Equal(t, "token", transport.token) } + +func TestSchemeAuthTransport_RoundTrip(t *testing.T) { + t.Parallel() + + rec := &recordingRoundTripper{} + transport := &schemeAuthTransport{scheme: "SSWS", token: "00aBcDeF", underlying: rec} + + req := httptest.NewRequest(http.MethodGet, "https://acme.okta.com/api/v1/users", nil) + _, err := transport.RoundTrip(req) + require.NoError(t, err) + + require.NotNil(t, rec.lastRequest) + assert.Equal(t, "SSWS 00aBcDeF", rec.lastRequest.Header.Get("Authorization")) + + // The original request must be left untouched (RoundTrip clones it). + assert.Empty(t, req.Header.Get("Authorization"), "original request must not be mutated") +} + +func TestAPIKeyConnection_Client_Scheme(t *testing.T) { + t.Parallel() + + conn := &APIKeyConnection{APIKey: "00aBcDeF", Scheme: "SSWS"} + + client, err := conn.Client(context.Background()) + require.NoError(t, err) + + transport, ok := client.Transport.(*schemeAuthTransport) + require.Truef(t, ok, "expected *schemeAuthTransport, got %T", client.Transport) + assert.Equal(t, "SSWS", transport.scheme) + assert.Equal(t, "00aBcDeF", transport.token) +} diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go index 223a6d817..d0f2daf79 100644 --- a/pkg/connector/provider/registry.go +++ b/pkg/connector/provider/registry.go @@ -71,11 +71,26 @@ 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) + // 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. + apiKeyModes := 0 + + if reg.APIKeyBasicAuth { + apiKeyModes++ + } + + if reg.APIKeyHeader != "" { + apiKeyModes++ + } + + if reg.APIKeyAuthScheme != "" { + apiKeyModes++ + } + + if apiKeyModes > 1 { + return fmt.Errorf("cannot register connector provider %q: APIKeyBasicAuth, APIKeyHeader, and APIKeyAuthScheme are mutually exclusive", reg.Provider) } r.mu.Lock() @@ -170,6 +185,19 @@ func (r *Registry) APIKeyUsesBasicAuth(p coredata.ConnectorProvider) bool { return false } +// APIKeyAuthScheme returns the non-Bearer Authorization scheme an API-key +// connection for the given provider must use to present its key (e.g. +// "SSWS" for Okta). Empty means the default `Authorization: Bearer` +// scheme. Returns empty for unknown providers and for providers that do +// not customise the scheme. +func (r *Registry) APIKeyAuthScheme(p coredata.ConnectorProvider) string { + if reg, ok := r.Get(p); ok { + return reg.APIKeyAuthScheme + } + + return "" +} + // 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 c0759da85..3fb2b486b 100644 --- a/pkg/connector/provider/registry_test.go +++ b/pkg/connector/provider/registry_test.go @@ -95,6 +95,20 @@ func TestRegistry_Register(t *testing.T) { assert.Contains(t, err.Error(), "mutually exclusive") }) + t.Run("APIKeyAuthScheme and APIKeyHeader mutually exclusive", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + APIKeyAuthScheme: "SSWS", + 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 a556f3219..581929464 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -87,6 +87,15 @@ type Registration struct { // APIKeyHeader. Consumed when the create-connector resolver builds // the APIKeyConnection. APIKeyBasicAuth bool + // APIKeyAuthScheme selects a non-Bearer Authorization scheme for an + // API-key connection: the key is sent as `Authorization: + // ` instead of `Authorization: Bearer `. Required by + // providers such as Okta whose API tokens use the `SSWS` scheme and + // reject Bearer. Empty (the default) keeps the standard Bearer + // scheme. Mutually exclusive with APIKeyHeader and APIKeyBasicAuth. + // Consumed when the create-connector resolver builds the + // APIKeyConnection. + APIKeyAuthScheme string // Factory closures — wired by Stages 2 and 3. NewDriver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error)