From 02ad4ad231dd671c920ff6f23444efcc913d9cbf Mon Sep 17 00:00:00 2001 From: Ashwin Nambiar Date: Mon, 27 Jul 2026 13:04:21 +0530 Subject: [PATCH 1/4] added support for apiKey support natively --- opensearch.go | 5 ++++- opensearch_internal_test.go | 2 +- opensearchtransport/opensearchtransport.go | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/opensearch.go b/opensearch.go index f9af65a81..bba47a9db 100644 --- a/opensearch.go +++ b/opensearch.go @@ -97,6 +97,8 @@ type Config struct { Username string // Username for HTTP Basic Authentication. // Password for HTTP Basic Authentication. Password string // #nosec G117 + // Api key based authentication + APIKey string // #nosec G117 Header http.Header // Global HTTP request header. @@ -446,6 +448,7 @@ func NewClient(cfg Config) (*Client, error) { URLs: urls, Username: cfg.Username, Password: cfg.Password, + APIKey: cfg.APIKey, Header: cfg.Header, CACert: cfg.CACert, @@ -597,7 +600,7 @@ func configKey(cfg Config) (ttlcache.Key, bool) { for _, a := range cfg.Addresses { b.String(a) } - b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password) + b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password).String(cfg.APIKey) // Header: sort keys and values for determinism. keys := make([]string, 0, len(cfg.Header)) diff --git a/opensearch_internal_test.go b/opensearch_internal_test.go index 1cc8703d4..45ee13e58 100644 --- a/opensearch_internal_test.go +++ b/opensearch_internal_test.go @@ -827,7 +827,7 @@ func TestCachedDefaultKeyNotCacheable(t *testing.T) { // TestConfigKey_FieldGuard fails loudly when Config grows a field without a // corresponding update to configKey, preventing a silent cache-key collision. func TestConfigKey_FieldGuard(t *testing.T) { - const knownFieldCount = 48 + const knownFieldCount = 49 got := reflect.TypeFor[Config]().NumField() require.Equal(t, knownFieldCount, got, "Config field count changed: audit configKey for the new field, then update knownFieldCount") diff --git a/opensearchtransport/opensearchtransport.go b/opensearchtransport/opensearchtransport.go index 6e63c728f..d27ca6066 100644 --- a/opensearchtransport/opensearchtransport.go +++ b/opensearchtransport/opensearchtransport.go @@ -137,6 +137,8 @@ type Config struct { Username string // Password for HTTP Basic Authentication. Password string // #nosec G117 + // ApiKey for HTTP + APIKey string // #nosec G117 Header http.Header CACert []byte @@ -449,6 +451,7 @@ type Transport struct { urls []*url.URL username string password string + apiKey string header http.Header userAgent string @@ -934,6 +937,7 @@ func New(cfg Config) (*Transport, error) { urls: cfg.URLs, username: cfg.Username, password: cfg.Password, + apiKey: cfg.APIKey, header: cfg.Header, signer: cfg.Signer, @@ -2043,6 +2047,11 @@ func (c *Transport) setReqAuth(u *url.URL, req *http.Request) { return } + if c.apiKey != "" { + req.Header.Set("Authorization", "ApiKey "+c.apiKey) + return + } + if c.username != "" && c.password != "" { req.SetBasicAuth(c.username, c.password) return From a3c975a9bc3bff1223d4fb2dfdbbc9e7366d62d1 Mon Sep 17 00:00:00 2001 From: Ashwin Nambiar Date: Mon, 27 Jul 2026 13:04:21 +0530 Subject: [PATCH 2/4] added support for apiKey support natively Signed-off-by: Ashwin Nambiar --- opensearch.go | 5 ++++- opensearch_internal_test.go | 2 +- opensearchtransport/opensearchtransport.go | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/opensearch.go b/opensearch.go index f9af65a81..bba47a9db 100644 --- a/opensearch.go +++ b/opensearch.go @@ -97,6 +97,8 @@ type Config struct { Username string // Username for HTTP Basic Authentication. // Password for HTTP Basic Authentication. Password string // #nosec G117 + // Api key based authentication + APIKey string // #nosec G117 Header http.Header // Global HTTP request header. @@ -446,6 +448,7 @@ func NewClient(cfg Config) (*Client, error) { URLs: urls, Username: cfg.Username, Password: cfg.Password, + APIKey: cfg.APIKey, Header: cfg.Header, CACert: cfg.CACert, @@ -597,7 +600,7 @@ func configKey(cfg Config) (ttlcache.Key, bool) { for _, a := range cfg.Addresses { b.String(a) } - b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password) + b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password).String(cfg.APIKey) // Header: sort keys and values for determinism. keys := make([]string, 0, len(cfg.Header)) diff --git a/opensearch_internal_test.go b/opensearch_internal_test.go index 1cc8703d4..45ee13e58 100644 --- a/opensearch_internal_test.go +++ b/opensearch_internal_test.go @@ -827,7 +827,7 @@ func TestCachedDefaultKeyNotCacheable(t *testing.T) { // TestConfigKey_FieldGuard fails loudly when Config grows a field without a // corresponding update to configKey, preventing a silent cache-key collision. func TestConfigKey_FieldGuard(t *testing.T) { - const knownFieldCount = 48 + const knownFieldCount = 49 got := reflect.TypeFor[Config]().NumField() require.Equal(t, knownFieldCount, got, "Config field count changed: audit configKey for the new field, then update knownFieldCount") diff --git a/opensearchtransport/opensearchtransport.go b/opensearchtransport/opensearchtransport.go index 6e63c728f..d27ca6066 100644 --- a/opensearchtransport/opensearchtransport.go +++ b/opensearchtransport/opensearchtransport.go @@ -137,6 +137,8 @@ type Config struct { Username string // Password for HTTP Basic Authentication. Password string // #nosec G117 + // ApiKey for HTTP + APIKey string // #nosec G117 Header http.Header CACert []byte @@ -449,6 +451,7 @@ type Transport struct { urls []*url.URL username string password string + apiKey string header http.Header userAgent string @@ -934,6 +937,7 @@ func New(cfg Config) (*Transport, error) { urls: cfg.URLs, username: cfg.Username, password: cfg.Password, + apiKey: cfg.APIKey, header: cfg.Header, signer: cfg.Signer, @@ -2043,6 +2047,11 @@ func (c *Transport) setReqAuth(u *url.URL, req *http.Request) { return } + if c.apiKey != "" { + req.Header.Set("Authorization", "ApiKey "+c.apiKey) + return + } + if c.username != "" && c.password != "" { req.SetBasicAuth(c.username, c.password) return From 92e272f9cb9d3fdb2b9143f4a48a61dc90016bb0 Mon Sep 17 00:00:00 2001 From: Ashwin Nambiar Date: Mon, 27 Jul 2026 14:41:37 +0530 Subject: [PATCH 3/4] update tests --- opensearch_internal_test.go | 24 +++++++ .../opensearchtransport_integration_test.go | 72 +++++++++++++++++++ .../transport_coverage_internal_test.go | 51 +++++++++++++ 3 files changed, 147 insertions(+) diff --git a/opensearch_internal_test.go b/opensearch_internal_test.go index 45ee13e58..4ed485f72 100644 --- a/opensearch_internal_test.go +++ b/opensearch_internal_test.go @@ -170,6 +170,27 @@ func TestClientConfiguration(t *testing.T) { require.Equal(t, "http://admin:admin@localhost:8080", u) }) + t.Run("With APIKey sends correct Authorization header", func(t *testing.T) { + const key = "dGVzdGlkOnRlc3RrZXk=" + var gotAuth string + c, err := NewClient(Config{ + Addresses: []string{"http://localhost:9200"}, + APIKey: key, + Transport: mockhttp.NewRoundTripFunc(t, func(req *http.Request) (*http.Response, error) { + gotAuth = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + }, nil + }), + }) + require.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, "/", nil) + _, _ = c.Stream(req) + require.Equal(t, "ApiKey "+key, gotAuth) + }) + t.Run("With DiscoverNodes on start", func(t *testing.T) { c, err := NewClient( Config{ @@ -765,6 +786,9 @@ func TestConfigKey(t *testing.T) { Config{Header: http.Header{"a": {"b", "c", "d"}}}, false, }, + {"diff api key", Config{APIKey: "key-a"}, Config{APIKey: "key-b"}, false}, + {"same api key", Config{APIKey: "key-a"}, Config{APIKey: "key-a"}, true}, + {"api key vs empty", Config{APIKey: "key-a"}, Config{}, false}, {"diff retry-on-status", Config{RetryOnStatus: []int{502}}, Config{RetryOnStatus: []int{503}}, false}, {"same retry-on-status", Config{RetryOnStatus: []int{502, 503}}, Config{RetryOnStatus: []int{502, 503}}, true}, { diff --git a/opensearchtransport/opensearchtransport_integration_test.go b/opensearchtransport/opensearchtransport_integration_test.go index c91798c45..c1b7458da 100644 --- a/opensearchtransport/opensearchtransport_integration_test.go +++ b/opensearchtransport/opensearchtransport_integration_test.go @@ -30,6 +30,7 @@ package opensearchtransport_test import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -271,3 +272,74 @@ func TestTransportCompression(t *testing.T) { res.Body.Close() } } + +func TestTransportAPIKeyAuth(t *testing.T) { + // API key creation requires the security plugin, which is only present in + // secure (HTTPS) integration environments. + if !testutil.IsSecure(t) { + t.Skip("TestTransportAPIKeyAuth requires SECURE_INTEGRATION=true (security plugin)") + } + + tptestutil.SkipIfVersion(t, "<", "2.0.0", "API key support requires OpenSearch 2.x+") + + config := testutil.ClientConfig(t) + u := mockhttp.GetOpenSearchURL(t) + + // Step 1: create an API key using admin credentials. + adminTP, err := opensearchtransport.New(opensearchtransport.Config{ + URLs: []*url.URL{u}, + Username: config.Client.Username, + Password: config.Client.Password, + Transport: config.Client.Transport, + }) + if err != nil { + t.Fatalf("failed to create admin transport: %s", err) + } + + createBody := strings.NewReader(`{"name":"go-client-test-key"}`) + createReq, _ := http.NewRequest(http.MethodPost, "/_security/api_key", createBody) + createReq.Header.Set("Content-Type", "application/json") + createRes, err := adminTP.Stream(createReq) + if err != nil { + t.Fatalf("failed to call /_security/api_key: %s", err) + } + defer createRes.Body.Close() + + if createRes.StatusCode != http.StatusOK { + body, _ := io.ReadAll(createRes.Body) + t.Fatalf("unexpected status %d creating API key: %s", createRes.StatusCode, body) + } + + var keyResp struct { + Encoded string `json:"encoded"` + } + if err := json.NewDecoder(createRes.Body).Decode(&keyResp); err != nil { + t.Fatalf("failed to decode API key response: %s", err) + } + if keyResp.Encoded == "" { + t.Fatal("API key response missing 'encoded' field") + } + + // Step 2: build a transport that authenticates solely with the API key. + keyTP, err := opensearchtransport.New(opensearchtransport.Config{ + URLs: []*url.URL{u}, + APIKey: keyResp.Encoded, + Transport: config.Client.Transport, + }) + if err != nil { + t.Fatalf("failed to create API key transport: %s", err) + } + + // Step 3: verify the API key grants access to GET /. + infoReq, _ := http.NewRequest(http.MethodGet, "/", nil) + infoRes, err := keyTP.Stream(infoReq) + if err != nil { + t.Fatalf("GET / with API key failed: %s", err) + } + defer infoRes.Body.Close() + io.ReadAll(infoRes.Body) //nolint:errcheck + + if infoRes.StatusCode != http.StatusOK { + t.Errorf("expected 200 from GET / with API key, got %d", infoRes.StatusCode) + } +} diff --git a/opensearchtransport/transport_coverage_internal_test.go b/opensearchtransport/transport_coverage_internal_test.go index 8dbede674..fdea289b8 100644 --- a/opensearchtransport/transport_coverage_internal_test.go +++ b/opensearchtransport/transport_coverage_internal_test.go @@ -164,6 +164,57 @@ func TestSetReqAuth(t *testing.T) { _, _, ok := req.BasicAuth() require.False(t, ok) }) + + t.Run("API key sets Authorization header", func(t *testing.T) { + t.Parallel() + dummyApiKey := "dGVzdGlkOnRlc3RrZXk=" + c := &Transport{apiKey: dummyApiKey} + u, _ := url.Parse("https://node1:9200") + req, _ := http.NewRequest(http.MethodGet, "/", nil) + c.setReqAuth(u, req) + + require.Equal(t, fmt.Sprintf("ApiKey %s", dummyApiKey), req.Header.Get("Authorization")) + }) + + t.Run("API key takes precedence over username/password", func(t *testing.T) { + t.Parallel() + dummyApiKey := "dGVzdGlkOnRlc3RrZXk=" + c := &Transport{apiKey: dummyApiKey, username: "admin", password: "secret"} + u, _ := url.Parse("https://node1:9200") + req, _ := http.NewRequest(http.MethodGet, "/", nil) + c.setReqAuth(u, req) + + require.Equal(t, fmt.Sprintf("ApiKey %s", dummyApiKey), req.Header.Get("Authorization")) + _, _, basicOK := req.BasicAuth() + require.False(t, basicOK) + }) + + t.Run("URL userinfo takes precedence over API key", func(t *testing.T) { + t.Parallel() + dummyApiKey := "dGVzdGlkOnRlc3RrZXk=" + c := &Transport{apiKey: dummyApiKey} + u, _ := url.Parse("https://url-user:url-pass@node1:9200") + req, _ := http.NewRequest(http.MethodGet, "/", nil) + c.setReqAuth(u, req) + + user, pass, ok := req.BasicAuth() + require.True(t, ok) + require.Equal(t, "url-user", user) + require.Equal(t, "url-pass", pass) + }) + + t.Run("existing Authorization header not overwritten by API key", func(t *testing.T) { + t.Parallel() + dummyApiKey := "dGVzdGlkOnRlc3RrZXk=" + c := &Transport{apiKey: dummyApiKey} + u, _ := url.Parse("https://node1:9200") + req, _ := http.NewRequest(http.MethodGet, "/", nil) + existingToken := "some-random-token" + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", existingToken)) + c.setReqAuth(u, req) + + require.Equal(t, fmt.Sprintf("Bearer %s", existingToken), req.Header.Get("Authorization")) + }) } func TestSignRequest_Coverage(t *testing.T) { From 8cb01ff902519ac8ae52483d17d438766b5c783c Mon Sep 17 00:00:00 2001 From: Ashwin Nambiar Date: Mon, 27 Jul 2026 14:56:21 +0530 Subject: [PATCH 4/4] fix: lint check --- opensearchtransport/opensearchtransport_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensearchtransport/opensearchtransport_integration_test.go b/opensearchtransport/opensearchtransport_integration_test.go index c1b7458da..350d9c898 100644 --- a/opensearchtransport/opensearchtransport_integration_test.go +++ b/opensearchtransport/opensearchtransport_integration_test.go @@ -337,7 +337,7 @@ func TestTransportAPIKeyAuth(t *testing.T) { t.Fatalf("GET / with API key failed: %s", err) } defer infoRes.Body.Close() - io.ReadAll(infoRes.Body) //nolint:errcheck + io.ReadAll(infoRes.Body) //nolint:errcheck // We dont need values here, just run this as side effect. if infoRes.StatusCode != http.StatusOK { t.Errorf("expected 200 from GET / with API key, got %d", infoRes.StatusCode)