From 2dd4f35bcaaf493ffba3e404b85b284b8bb2f2b3 Mon Sep 17 00:00:00 2001 From: Pavel Lazureykis Date: Fri, 15 May 2026 13:33:32 -0400 Subject: [PATCH 1/4] [extension/basicauth] Extract shared basic auth logic into internal package Move reusable basic auth primitives (header parsing, credential validation, HTTP RoundTripper, gRPC PerRPCCredentials) into extension/internal/basicauth so that other extensions needing basic auth can share them without duplicating code. Assisted-by: Claude Opus 4.6 --- extension/basicauthextension/extension.go | 168 ++------------- .../basicauthextension/extension_test.go | 7 +- extension/basicauthextension/go.mod | 3 + extension/basicauthextension/go.sum | 4 +- extension/internal/basicauth/Makefile | 1 + extension/internal/basicauth/basicauth.go | 193 ++++++++++++++++++ .../internal/basicauth/basicauth_test.go | 165 +++++++++++++++ extension/internal/basicauth/go.mod | 22 ++ extension/internal/basicauth/go.sum | 57 ++++++ versions.yaml | 1 + 10 files changed, 460 insertions(+), 161 deletions(-) create mode 100644 extension/internal/basicauth/Makefile create mode 100644 extension/internal/basicauth/basicauth.go create mode 100644 extension/internal/basicauth/basicauth_test.go create mode 100644 extension/internal/basicauth/go.mod create mode 100644 extension/internal/basicauth/go.sum diff --git a/extension/basicauthextension/extension.go b/extension/basicauthextension/extension.go index 16525e87d7454..832ff2b393af1 100644 --- a/extension/basicauthextension/extension.go +++ b/extension/basicauthextension/extension.go @@ -5,31 +5,29 @@ package basicauthextension // import "github.com/open-telemetry/opentelemetry-co import ( "context" - "encoding/base64" "errors" "fmt" "io" "net/http" "os" "strings" - "sync/atomic" "github.com/tg123/go-htpasswd" - "go.opentelemetry.io/collector/client" "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/extension" "go.opentelemetry.io/collector/extension/extensionauth" "go.uber.org/zap" creds "google.golang.org/grpc/credentials" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth" "github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile" ) var ( - errNoAuth = errors.New("no basic auth provided") - errInvalidCredentials = errors.New("invalid credentials") - errInvalidSchemePrefix = errors.New("invalid authorization scheme prefix") - errInvalidFormat = errors.New("invalid authorization format") + errNoAuth = basicauth.ErrNoAuth + errInvalidCredentials = basicauth.ErrInvalidCredentials + errInvalidSchemePrefix = basicauth.ErrInvalidSchemePrefix + errInvalidFormat = basicauth.ErrInvalidFormat ) func newClientAuthExtension(cfg *Config) *basicAuthClient { @@ -86,129 +84,7 @@ func (ba *basicAuthServer) Start(_ context.Context, _ component.Host) error { } func (ba *basicAuthServer) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) { - auth := getAuthHeader(headers) - if auth == "" { - return ctx, errNoAuth - } - - authData, err := parseBasicAuth(auth) - if err != nil { - return ctx, err - } - - if !ba.matchFunc(authData.username, authData.password) { - return ctx, errInvalidCredentials - } - - cl := client.FromContext(ctx) - cl.Auth = authData - return client.NewContext(ctx, cl), nil -} - -func getAuthHeader(h map[string][]string) string { - const ( - canonicalHeaderKey = "Authorization" - metadataKey = "authorization" - ) - - authHeaders, ok := h[canonicalHeaderKey] - - if !ok { - authHeaders, ok = h[metadataKey] - } - - if !ok { - for k, v := range h { - if strings.EqualFold(k, metadataKey) { - authHeaders = v - break - } - } - } - - if len(authHeaders) == 0 { - return "" - } - - return authHeaders[0] -} - -// See: https://github.com/golang/go/blob/1a8b4e05b1ff7a52c6d40fad73bcad612168d094/src/net/http/request.go#L950 -func parseBasicAuth(auth string) (*authData, error) { - const prefix = "Basic " - if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { - return nil, errInvalidSchemePrefix - } - - encoded := auth[len(prefix):] - decodedBytes, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, errInvalidFormat - } - decoded := string(decodedBytes) - - before, after, ok := strings.Cut(decoded, ":") - if !ok { - return nil, errInvalidFormat - } - - return &authData{ - username: before, - password: after, - raw: encoded, - }, nil -} - -var _ client.AuthData = (*authData)(nil) - -type authData struct { - username string - password string - raw string -} - -func (a *authData) GetAttribute(name string) any { - switch name { - case "username": - return a.username - case "raw": - return a.raw - default: - return nil - } -} - -func (*authData) GetAttributeNames() []string { - return []string{"username", "raw"} -} - -// perRPCAuth is a gRPC credentials.PerRPCCredentials implementation that returns an 'authorization' header. -type perRPCAuth struct { - client *basicAuthClient -} - -// GetRequestMetadata returns the request metadata to be used with the RPC. -func (p *perRPCAuth) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { - return *p.client.grpcMetadata.Load(), nil -} - -// RequireTransportSecurity always returns true for this implementation. -func (*perRPCAuth) RequireTransportSecurity() bool { - return true -} - -type basicAuthRoundTripper struct { - base http.RoundTripper - client *basicAuthClient -} - -func (b *basicAuthRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { - newRequest := request.Clone(request.Context()) - if newRequest.Header == nil { - newRequest.Header = make(http.Header) - } - newRequest.SetBasicAuth(b.client.username(), b.client.password()) - return b.base.RoundTrip(newRequest) + return basicauth.Authenticate(ctx, headers, ba.matchFunc) } var ( @@ -222,25 +98,15 @@ type basicAuthClient struct { logger *zap.Logger usernameResolver credentialsfile.ValueResolver passwordResolver credentialsfile.ValueResolver - grpcMetadata atomic.Pointer[map[string]string] -} - -func (ba *basicAuthClient) updateGRPCMetadata() { - encoded := base64.StdEncoding.EncodeToString([]byte(ba.username() + ":" + ba.password())) - m := map[string]string{ - "authorization": fmt.Sprintf("Basic %s", encoded), - } - ba.grpcMetadata.Store(&m) } func (ba *basicAuthClient) Start(ctx context.Context, _ component.Host) error { if ba.clientAuth == nil { return errNoCredentialSource } - onChange := func(_ string) { ba.updateGRPCMetadata() } ca := ba.clientAuth if ca.Username != "" || ca.UsernameFile != "" { - r, err := credentialsfile.NewValueResolver(ca.Username, ca.UsernameFile, ba.logger, credentialsfile.WithOnChange(onChange)) + r, err := credentialsfile.NewValueResolver(ca.Username, ca.UsernameFile, ba.logger) if err != nil { return err } @@ -250,7 +116,7 @@ func (ba *basicAuthClient) Start(ctx context.Context, _ component.Host) error { ba.usernameResolver = r } if string(ca.Password) != "" || ca.PasswordFile != "" { - r, err := credentialsfile.NewValueResolver(string(ca.Password), ca.PasswordFile, ba.logger, credentialsfile.WithOnChange(onChange)) + r, err := credentialsfile.NewValueResolver(string(ca.Password), ca.PasswordFile, ba.logger) if err != nil { return err } @@ -259,7 +125,6 @@ func (ba *basicAuthClient) Start(ctx context.Context, _ component.Host) error { } ba.passwordResolver = r } - ba.updateGRPCMetadata() return nil } @@ -274,7 +139,7 @@ func (ba *basicAuthClient) Shutdown(_ context.Context) error { return errors.Join(errs...) } -func (ba *basicAuthClient) username() string { +func (ba *basicAuthClient) Username() string { if ba.usernameResolver != nil { return ba.usernameResolver.Value() } @@ -284,7 +149,7 @@ func (ba *basicAuthClient) username() string { return "" } -func (ba *basicAuthClient) password() string { +func (ba *basicAuthClient) Password() string { if ba.passwordResolver != nil { return ba.passwordResolver.Value() } @@ -295,18 +160,9 @@ func (ba *basicAuthClient) password() string { } func (ba *basicAuthClient) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { - if strings.Contains(ba.username(), ":") { - return nil, errInvalidFormat - } - return &basicAuthRoundTripper{ - base: base, - client: ba, - }, nil + return basicauth.NewRoundTripper(base, ba) } func (ba *basicAuthClient) PerRPCCredentials() (creds.PerRPCCredentials, error) { - if strings.Contains(ba.username(), ":") { - return nil, errInvalidFormat - } - return &perRPCAuth{client: ba}, nil + return basicauth.NewPerRPCCredentials(ba) } diff --git a/extension/basicauthextension/extension_test.go b/extension/basicauthextension/extension_test.go index afae1d6e7e3d1..fe4c1dbfc55e3 100644 --- a/extension/basicauthextension/extension_test.go +++ b/extension/basicauthextension/extension_test.go @@ -204,15 +204,16 @@ func TestBasicAuth_ServerInvalid(t *testing.T) { func TestPerRPCAuth(t *testing.T) { t.Parallel() - client := &basicAuthClient{ + ext := &basicAuthClient{ clientAuth: &ClientAuthSettings{ Username: "username", Password: "passwordxxx", }, } - require.NoError(t, client.Start(t.Context(), componenttest.NewNopHost())) + require.NoError(t, ext.Start(t.Context(), componenttest.NewNopHost())) - rpcAuth := &perRPCAuth{client: client} + rpcAuth, err := ext.PerRPCCredentials() + require.NoError(t, err) md, err := rpcAuth.GetRequestMetadata(t.Context()) assert.NoError(t, err) assert.Equal(t, map[string]string{ diff --git a/extension/basicauthextension/go.mod b/extension/basicauthextension/go.mod index 67af12dcc3e1f..e3ac24936c9e4 100644 --- a/extension/basicauthextension/go.mod +++ b/extension/basicauthextension/go.mod @@ -3,6 +3,7 @@ module github.com/open-telemetry/opentelemetry-collector-contrib/extension/basic go 1.25.0 require ( + github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth v0.152.0 github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile v0.152.0 github.com/stretchr/testify v1.11.1 github.com/tg123/go-htpasswd v1.2.4 @@ -63,4 +64,6 @@ retract ( v0.65.0 ) +replace github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth => ../internal/basicauth + replace github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile => ../internal/credentialsfile diff --git a/extension/basicauthextension/go.sum b/extension/basicauthextension/go.sum index 6b15dcf0c5eb0..0f387bb3e56ab 100644 --- a/extension/basicauthextension/go.sum +++ b/extension/basicauthextension/go.sum @@ -69,8 +69,8 @@ go.opentelemetry.io/collector/confmap v1.58.1-0.20260514231715-e7f22744c28c h1:k go.opentelemetry.io/collector/confmap v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:2O/WadVBFwRzpO+3skcvjqDxD+OaS0TKKDDpPBaR4bs= go.opentelemetry.io/collector/confmap/xconfmap v0.152.1-0.20260514231715-e7f22744c28c h1:yZ9FH7kDgI29mCBSeeGnsNlREA5uIM+8KL0D9q3WFXg= go.opentelemetry.io/collector/confmap/xconfmap v0.152.1-0.20260514231715-e7f22744c28c/go.mod h1:ff7vNJZ/kkN9pMEXRM0T9TeaKcCZE226I2NlJhKXF3I= -go.opentelemetry.io/collector/consumer v1.58.0 h1:R9qWrp4xlZTrT+Ph10vgrjnaIzkW20/RAbXs2F7PhPA= -go.opentelemetry.io/collector/consumer v1.58.0/go.mod h1:ptX5120b3Py+vqBUmjvl5VJ8yYpSQOto6Vv9b12f2d4= +go.opentelemetry.io/collector/consumer v1.58.1-0.20260514231715-e7f22744c28c h1:6+69d3VPtD+LIv3OhVA6xIIhtj9RtJR60qpLKd0aBRQ= +go.opentelemetry.io/collector/consumer v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:ptX5120b3Py+vqBUmjvl5VJ8yYpSQOto6Vv9b12f2d4= go.opentelemetry.io/collector/extension v1.58.1-0.20260514231715-e7f22744c28c h1:4s4otIU14ie+VJhWYbMVV2NZLt4VLmlQ57srAD1ZoMg= go.opentelemetry.io/collector/extension v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:eiWWL+MwUOUMD18mo01sNLic9RZlRBbQqyRs3URbh3U= go.opentelemetry.io/collector/extension/extensionauth v1.58.1-0.20260514231715-e7f22744c28c h1:5UNmzqRbVL0Xg0x2Q0tHGG/VMukE624dCLjN3AEWMes= diff --git a/extension/internal/basicauth/Makefile b/extension/internal/basicauth/Makefile new file mode 100644 index 0000000000000..bdd863a203be8 --- /dev/null +++ b/extension/internal/basicauth/Makefile @@ -0,0 +1 @@ +include ../../../Makefile.Common diff --git a/extension/internal/basicauth/basicauth.go b/extension/internal/basicauth/basicauth.go new file mode 100644 index 0000000000000..7314a86a6721e --- /dev/null +++ b/extension/internal/basicauth/basicauth.go @@ -0,0 +1,193 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package basicauth // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth" + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + + "go.opentelemetry.io/collector/client" + creds "google.golang.org/grpc/credentials" +) + +var ( + ErrNoAuth = errors.New("no basic auth provided") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidSchemePrefix = errors.New("invalid authorization scheme prefix") + ErrInvalidFormat = errors.New("invalid authorization format") +) + +// CredentialProvider supplies username and password for client-side basic auth. +type CredentialProvider interface { + Username() string + Password() string +} + +// AuthData implements client.AuthData for basic auth. +type AuthData struct { + username string + password string + raw string +} + +var _ client.AuthData = (*AuthData)(nil) + +func (a *AuthData) GetAttribute(name string) any { + switch name { + case "username": + return a.username + case "raw": + return a.raw + default: + return nil + } +} + +func (*AuthData) GetAttributeNames() []string { + return []string{"username", "raw"} +} + +// AuthDataUsername returns the username from AuthData. +func (a *AuthData) AuthDataUsername() string { return a.username } + +// AuthDataPassword returns the password from AuthData. +func (a *AuthData) AuthDataPassword() string { return a.password } + +// GetAuthHeader extracts the Authorization header value from a header map, +// handling canonical, lowercase, and case-insensitive lookups. +func GetAuthHeader(h map[string][]string) string { + const ( + canonicalHeaderKey = "Authorization" + metadataKey = "authorization" + ) + + authHeaders, ok := h[canonicalHeaderKey] + + if !ok { + authHeaders, ok = h[metadataKey] + } + + if !ok { + for k, v := range h { + if strings.EqualFold(k, metadataKey) { + authHeaders = v + break + } + } + } + + if len(authHeaders) == 0 { + return "" + } + + return authHeaders[0] +} + +// ParseBasicAuth parses a "Basic " authorization header value. +func ParseBasicAuth(auth string) (*AuthData, error) { + const prefix = "Basic " + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return nil, ErrInvalidSchemePrefix + } + + encoded := auth[len(prefix):] + decodedBytes, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, ErrInvalidFormat + } + decoded := string(decodedBytes) + + before, after, ok := strings.Cut(decoded, ":") + if !ok { + return nil, ErrInvalidFormat + } + + return &AuthData{ + username: before, + password: after, + raw: encoded, + }, nil +} + +// Authenticate performs server-side basic auth validation against the given header map. +// It extracts the Authorization header, parses the credentials, and validates them +// using the provided match function. On success, it stores the auth data in the context. +func Authenticate(ctx context.Context, headers map[string][]string, matchFunc func(username, password string) bool) (context.Context, error) { + auth := GetAuthHeader(headers) + if auth == "" { + return ctx, ErrNoAuth + } + + authData, err := ParseBasicAuth(auth) + if err != nil { + return ctx, err + } + + if !matchFunc(authData.username, authData.password) { + return ctx, ErrInvalidCredentials + } + + cl := client.FromContext(ctx) + cl.Auth = authData + return client.NewContext(ctx, cl), nil +} + +// RoundTripper wraps a base http.RoundTripper to inject basic auth credentials. +type RoundTripper struct { + Base http.RoundTripper + Provider CredentialProvider +} + +func (rt *RoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + newRequest := request.Clone(request.Context()) + if newRequest.Header == nil { + newRequest.Header = make(http.Header) + } + newRequest.SetBasicAuth(rt.Provider.Username(), rt.Provider.Password()) + return rt.Base.RoundTrip(newRequest) +} + +// NewRoundTripper creates an http.RoundTripper that adds basic auth from the provider. +func NewRoundTripper(base http.RoundTripper, provider CredentialProvider) (http.RoundTripper, error) { + if strings.Contains(provider.Username(), ":") { + return nil, ErrInvalidFormat + } + return &RoundTripper{Base: base, Provider: provider}, nil +} + +// PerRPCAuth implements grpc credentials.PerRPCCredentials using a CredentialProvider. +type PerRPCAuth struct { + Provider CredentialProvider +} + +func (p *PerRPCAuth) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + encoded := base64.StdEncoding.EncodeToString( + []byte(p.Provider.Username() + ":" + p.Provider.Password()), + ) + return map[string]string{ + "authorization": fmt.Sprintf("Basic %s", encoded), + }, nil +} + +func (*PerRPCAuth) RequireTransportSecurity() bool { return true } + +// NewPerRPCCredentials creates gRPC PerRPCCredentials that add basic auth from the provider. +func NewPerRPCCredentials(provider CredentialProvider) (creds.PerRPCCredentials, error) { + if strings.Contains(provider.Username(), ":") { + return nil, ErrInvalidFormat + } + return &PerRPCAuth{Provider: provider}, nil +} + +// EncodeGRPCMetadata returns the "authorization" metadata map for gRPC basic auth. +func EncodeGRPCMetadata(username, password string) map[string]string { + encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + return map[string]string{ + "authorization": fmt.Sprintf("Basic %s", encoded), + } +} diff --git a/extension/internal/basicauth/basicauth_test.go b/extension/internal/basicauth/basicauth_test.go new file mode 100644 index 0000000000000..d1f87eab2c474 --- /dev/null +++ b/extension/internal/basicauth/basicauth_test.go @@ -0,0 +1,165 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package basicauth + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/client" +) + +type staticProvider struct { + username string + password string +} + +func (s *staticProvider) Username() string { return s.username } +func (s *staticProvider) Password() string { return s.password } + +func TestGetAuthHeader(t *testing.T) { + tests := []struct { + name string + headers map[string][]string + want string + }{ + { + name: "canonical", + headers: map[string][]string{"Authorization": {"Basic abc"}}, + want: "Basic abc", + }, + { + name: "lowercase", + headers: map[string][]string{"authorization": {"Basic abc"}}, + want: "Basic abc", + }, + { + name: "mixed case", + headers: map[string][]string{"aUtHoRiZaTiOn": {"Basic abc"}}, + want: "Basic abc", + }, + { + name: "missing", + headers: map[string][]string{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, GetAuthHeader(tt.headers)) + }) + } +} + +func TestParseBasicAuth(t *testing.T) { + t.Run("valid", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) + ad, err := ParseBasicAuth("Basic " + encoded) + require.NoError(t, err) + assert.Equal(t, "user", ad.AuthDataUsername()) + assert.Equal(t, "pass", ad.AuthDataPassword()) + assert.Equal(t, encoded, ad.GetAttribute("raw")) + assert.Equal(t, "user", ad.GetAttribute("username")) + assert.Nil(t, ad.GetAttribute("nonexistent")) + assert.Equal(t, []string{"username", "raw"}, ad.GetAttributeNames()) + }) + + t.Run("wrong scheme", func(t *testing.T) { + _, err := ParseBasicAuth("Bearer token") + assert.ErrorIs(t, err, ErrInvalidSchemePrefix) + }) + + t.Run("invalid base64", func(t *testing.T) { + _, err := ParseBasicAuth("Basic not-valid-base64!") + assert.ErrorIs(t, err, ErrInvalidFormat) + }) + + t.Run("missing colon", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("nocolon")) + _, err := ParseBasicAuth("Basic " + encoded) + assert.ErrorIs(t, err, ErrInvalidFormat) + }) +} + +func TestAuthenticate(t *testing.T) { + matchFunc := func(username, password string) bool { + return username == "alice" && password == "secret" + } + + t.Run("valid credentials", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("alice:secret")) + headers := map[string][]string{"Authorization": {"Basic " + encoded}} + ctx, err := Authenticate(t.Context(), headers, matchFunc) + require.NoError(t, err) + cl := client.FromContext(ctx) + assert.Equal(t, "alice", cl.Auth.GetAttribute("username")) + }) + + t.Run("invalid credentials", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("alice:wrong")) + headers := map[string][]string{"Authorization": {"Basic " + encoded}} + _, err := Authenticate(t.Context(), headers, matchFunc) + assert.ErrorIs(t, err, ErrInvalidCredentials) + }) + + t.Run("no header", func(t *testing.T) { + _, err := Authenticate(t.Context(), map[string][]string{}, matchFunc) + assert.ErrorIs(t, err, ErrNoAuth) + }) +} + +type captureTransport struct{ last *http.Request } + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.last = req + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestRoundTripper(t *testing.T) { + provider := &staticProvider{username: "user", password: "pass"} + capture := &captureTransport{} + rt, err := NewRoundTripper(capture, provider) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")) + assert.Equal(t, expected, capture.last.Header.Get("Authorization")) +} + +func TestRoundTripper_ColonInUsername(t *testing.T) { + provider := &staticProvider{username: "user:name", password: "pass"} + _, err := NewRoundTripper(&captureTransport{}, provider) + assert.ErrorIs(t, err, ErrInvalidFormat) +} + +func TestPerRPCCredentials(t *testing.T) { + provider := &staticProvider{username: "user", password: "pass"} + cred, err := NewPerRPCCredentials(provider) + require.NoError(t, err) + + md, err := cred.GetRequestMetadata(t.Context()) + require.NoError(t, err) + expected := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("user:pass"))) + assert.Equal(t, expected, md["authorization"]) + assert.True(t, cred.RequireTransportSecurity()) +} + +func TestPerRPCCredentials_ColonInUsername(t *testing.T) { + provider := &staticProvider{username: "user:name", password: "pass"} + _, err := NewPerRPCCredentials(provider) + assert.ErrorIs(t, err, ErrInvalidFormat) +} + +func TestEncodeGRPCMetadata(t *testing.T) { + m := EncodeGRPCMetadata("user", "pass") + expected := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("user:pass"))) + assert.Equal(t, expected, m["authorization"]) +} diff --git a/extension/internal/basicauth/go.mod b/extension/internal/basicauth/go.mod new file mode 100644 index 0000000000000..4c9c657911939 --- /dev/null +++ b/extension/internal/basicauth/go.mod @@ -0,0 +1,22 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/collector/client v1.58.1-0.20260514231715-e7f22744c28c + google.golang.org/grpc v1.81.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + go.opentelemetry.io/collector/consumer v1.58.1-0.20260514231715-e7f22744c28c // indirect + go.opentelemetry.io/collector/featuregate v1.58.1-0.20260514231715-e7f22744c28c // indirect + go.opentelemetry.io/collector/pdata v1.58.1-0.20260514231715-e7f22744c28c // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/extension/internal/basicauth/go.sum b/extension/internal/basicauth/go.sum new file mode 100644 index 0000000000000..773e616557797 --- /dev/null +++ b/extension/internal/basicauth/go.sum @@ -0,0 +1,57 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/collector/client v1.58.1-0.20260514231715-e7f22744c28c h1:ZLSrHqLxptYyL9fktvjiY093u4b65/bL8TeTvHXFWEo= +go.opentelemetry.io/collector/client v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:vib5K6C0F6y0i5ofWmO4VlYu9PHrJ5hyAQOkk74JvrY= +go.opentelemetry.io/collector/consumer v1.58.1-0.20260514231715-e7f22744c28c h1:6+69d3VPtD+LIv3OhVA6xIIhtj9RtJR60qpLKd0aBRQ= +go.opentelemetry.io/collector/consumer v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:ptX5120b3Py+vqBUmjvl5VJ8yYpSQOto6Vv9b12f2d4= +go.opentelemetry.io/collector/featuregate v1.58.1-0.20260514231715-e7f22744c28c h1:BZFW0uJTjqi62M4D8lT/1l/t8gg8weRsi09wxN0QywA= +go.opentelemetry.io/collector/featuregate v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:4ga1QBMPEejXXmpyJS8lmaRpknJ3Lb9Bvk6e420bUFU= +go.opentelemetry.io/collector/pdata v1.58.1-0.20260514231715-e7f22744c28c h1:lCrVlOGmDDim/4nRYJ6R9YozsXPtyI7evKijtoz2wAw= +go.opentelemetry.io/collector/pdata v1.58.1-0.20260514231715-e7f22744c28c/go.mod h1:4vZtODINbC/JF3eGocnatdImzbRHseOywIcr+aULjCg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/versions.yaml b/versions.yaml index 1fd2b81a01ca1..8c9e919ea124d 100644 --- a/versions.yaml +++ b/versions.yaml @@ -85,6 +85,7 @@ module-sets: - github.com/open-telemetry/opentelemetry-collector-contrib/extension/azureauthextension - github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension - github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension + - github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/basicauth - github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile - github.com/open-telemetry/opentelemetry-collector-contrib/extension/cgroupruntimeextension - github.com/open-telemetry/opentelemetry-collector-contrib/extension/datadogextension From 80c24cf3b97ee40cd43787adc0c66bda1ec97e3a Mon Sep 17 00:00:00 2001 From: Pavel Lazureykis Date: Fri, 15 May 2026 13:47:46 -0400 Subject: [PATCH 2/4] Remove unused exports from internal basicauth package Remove AuthDataUsername(), AuthDataPassword(), and EncodeGRPCMetadata() which had no production callers. Keep the API surface minimal. Assisted-by: Claude Opus 4.5 --- extension/internal/basicauth/basicauth.go | 14 -------------- extension/internal/basicauth/basicauth_test.go | 10 +--------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/extension/internal/basicauth/basicauth.go b/extension/internal/basicauth/basicauth.go index 7314a86a6721e..904167f3cd664 100644 --- a/extension/internal/basicauth/basicauth.go +++ b/extension/internal/basicauth/basicauth.go @@ -52,12 +52,6 @@ func (*AuthData) GetAttributeNames() []string { return []string{"username", "raw"} } -// AuthDataUsername returns the username from AuthData. -func (a *AuthData) AuthDataUsername() string { return a.username } - -// AuthDataPassword returns the password from AuthData. -func (a *AuthData) AuthDataPassword() string { return a.password } - // GetAuthHeader extracts the Authorization header value from a header map, // handling canonical, lowercase, and case-insensitive lookups. func GetAuthHeader(h map[string][]string) string { @@ -183,11 +177,3 @@ func NewPerRPCCredentials(provider CredentialProvider) (creds.PerRPCCredentials, } return &PerRPCAuth{Provider: provider}, nil } - -// EncodeGRPCMetadata returns the "authorization" metadata map for gRPC basic auth. -func EncodeGRPCMetadata(username, password string) map[string]string { - encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - return map[string]string{ - "authorization": fmt.Sprintf("Basic %s", encoded), - } -} diff --git a/extension/internal/basicauth/basicauth_test.go b/extension/internal/basicauth/basicauth_test.go index d1f87eab2c474..68f2feb618640 100644 --- a/extension/internal/basicauth/basicauth_test.go +++ b/extension/internal/basicauth/basicauth_test.go @@ -61,10 +61,8 @@ func TestParseBasicAuth(t *testing.T) { encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) ad, err := ParseBasicAuth("Basic " + encoded) require.NoError(t, err) - assert.Equal(t, "user", ad.AuthDataUsername()) - assert.Equal(t, "pass", ad.AuthDataPassword()) - assert.Equal(t, encoded, ad.GetAttribute("raw")) assert.Equal(t, "user", ad.GetAttribute("username")) + assert.Equal(t, encoded, ad.GetAttribute("raw")) assert.Nil(t, ad.GetAttribute("nonexistent")) assert.Equal(t, []string{"username", "raw"}, ad.GetAttributeNames()) }) @@ -157,9 +155,3 @@ func TestPerRPCCredentials_ColonInUsername(t *testing.T) { _, err := NewPerRPCCredentials(provider) assert.ErrorIs(t, err, ErrInvalidFormat) } - -func TestEncodeGRPCMetadata(t *testing.T) { - m := EncodeGRPCMetadata("user", "pass") - expected := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("user:pass"))) - assert.Equal(t, expected, m["authorization"]) -} From 79e2ad489afa4ca000edd4d52e4c5ebb8735921d Mon Sep 17 00:00:00 2001 From: Pavel Lazureykis Date: Fri, 15 May 2026 14:08:44 -0400 Subject: [PATCH 3/4] Add extension/internal/basicauth to tidylist --- internal/tidylist/tidylist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tidylist/tidylist.txt b/internal/tidylist/tidylist.txt index 01bfb1ee0609a..85672b64104c7 100644 --- a/internal/tidylist/tidylist.txt +++ b/internal/tidylist/tidylist.txt @@ -143,6 +143,7 @@ extension/asapauthextension internal/aws/proxy extension/awsproxy extension/azureauthextension +extension/internal/basicauth extension/internal/credentialsfile extension/basicauthextension extension/bearertokenauthextension From 99af9069bd040fa606473c68f821f42b895f4638 Mon Sep 17 00:00:00 2001 From: Pavel Lazureykis Date: Fri, 15 May 2026 14:18:10 -0400 Subject: [PATCH 4/4] Update .codecov.yml for extension/internal/basicauth --- .codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.codecov.yml b/.codecov.yml index 804fcbf338774..9fbb371ed6060 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -345,6 +345,10 @@ component_management: name: extension_httpforwarder paths: - extension/httpforwarderextension/** + - component_id: extension_internal_basicauth + name: extension_internal_basicauth + paths: + - extension/internal/basicauth/** - component_id: extension_internal_credentialsfile name: extension_internal_credentialsfile paths: