Skip to content

Add support for multiple bearer tokens #38148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .chloggen/multiple_bearer_tokens_support.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
component: bearertokenauthextension

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Add the ability to configure multiple bearer tokens for the same endpoint.

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [38148]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:

# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: [user, api]
7 changes: 7 additions & 0 deletions extension/bearertokenauthextension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ The authenticator type has to be set to `bearertokenauth`.

- `token`: Static authorization token that needs to be sent on every gRPC client call as metadata.

- `tokens`: A list of static authorization tokens, one of which needs to be sent on every gRPC client call as metadata.

- `filename`: Name of file that contains a authorization token that needs to be sent in every client call.

Either one of `token` or `filename` field is required. If both are specified, then the `token` field value is **ignored**. In any case, the value of the token will be prepended by `${scheme}` before being sent as a value of "authorization" key in the request header in case of HTTP and metadata in case of gRPC.
Expand All @@ -40,6 +42,11 @@ extensions:
bearertokenauth/withscheme:
scheme: "Bearer"
token: "randomtoken"
bearertokenauth/multipletokens:
scheme: "Bearer"
tokens:
- "randomtoken"
- "thistokenalsoworks"

receivers:
hostmetrics:
Expand Down
75 changes: 54 additions & 21 deletions extension/bearertokenauthextension/bearertokenauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"net/http"
"os"
"strings"
"sync/atomic"

"github.com/fsnotify/fsnotify"
Expand Down Expand Up @@ -43,8 +44,8 @@ var (

// BearerTokenAuth is an implementation of auth.Client. It embeds a static authorization "bearer" token in every rpc call.
type BearerTokenAuth struct {
scheme string
authorizationValueAtomic atomic.Value
scheme string
authorizationValuesAtomic atomic.Value

shutdownCH chan struct{}

Expand All @@ -55,15 +56,25 @@ type BearerTokenAuth struct {
var _ auth.Client = (*BearerTokenAuth)(nil)

func newBearerTokenAuth(cfg *Config, logger *zap.Logger) *BearerTokenAuth {
if cfg.Filename != "" && cfg.BearerToken != "" {
logger.Warn("a filename is specified. Configured token is ignored!")
if cfg.Filename != "" && (cfg.BearerToken != "" || len(cfg.Tokens) > 0) {
logger.Warn("a filename is specified. Configured token(s) is ignored!")
}
a := &BearerTokenAuth{
scheme: cfg.Scheme,
filename: cfg.Filename,
logger: logger,
}
a.setAuthorizationValue(string(cfg.BearerToken))
if len(cfg.Tokens) > 0 {
tokens := make([]string, len(cfg.Tokens))
for i, token := range cfg.Tokens {
tokens[i] = string(token)
}
a.setAuthorizationValues(tokens) // Store tokens
} else if cfg.BearerToken != "" {
a.setAuthorizationValues([]string{string(cfg.BearerToken)}) // Store token
} else if cfg.Filename != "" {
a.refreshToken() // Load tokens from file
}
return a
}

Expand Down Expand Up @@ -129,28 +140,48 @@ func (b *BearerTokenAuth) startWatcher(ctx context.Context, watcher *fsnotify.Wa
}
}

// Reloads token from file
func (b *BearerTokenAuth) refreshToken() {
b.logger.Info("refresh token", zap.String("filename", b.filename))
token, err := os.ReadFile(b.filename)
tokenData, err := os.ReadFile(b.filename)
if err != nil {
b.logger.Error(err.Error())
return
}
b.setAuthorizationValue(string(token))

tokens := strings.Split(string(tokenData), "\n")
for i, token := range tokens {
tokens[i] = strings.TrimSpace(token)
}
b.setAuthorizationValues(tokens) // Stores new tokens
}

func (b *BearerTokenAuth) setAuthorizationValue(token string) {
value := token
if b.scheme != "" {
value = b.scheme + " " + value
func (b *BearerTokenAuth) setAuthorizationValues(tokens []string) {
values := make([]string, len(tokens))
for i, token := range tokens {
if b.scheme != "" {
values[i] = b.scheme + " " + token
} else {
values[i] = token
}
}
b.authorizationValueAtomic.Store(value)
b.authorizationValuesAtomic.Store(values)
}

// authorizationValue returns the Authorization header/metadata value
// authorizationValues returns the Authorization header/metadata values
// to set for client auth, and expected values for server auth.
func (b *BearerTokenAuth) authorizationValues() []string {
return b.authorizationValuesAtomic.Load().([]string)
}

// authorizationValue returns the first Authorization header/metadata value
// to set for client auth, and expected value for server auth.
func (b *BearerTokenAuth) authorizationValue() string {
return b.authorizationValueAtomic.Load().(string)
values := b.authorizationValues()
if len(values) > 0 {
return values[0] // Return the first token
}
return ""
}

// Shutdown of BearerTokenAuth does nothing and returns nil
Expand Down Expand Up @@ -183,7 +214,7 @@ func (b *BearerTokenAuth) RoundTripper(base http.RoundTripper) (http.RoundTrippe
}, nil
}

// Authenticate checks whether the given context contains valid auth data.
// Authenticate checks whether the given context contains valid auth data. Validates tokens from clients trying to access the service (incoming requests)
func (b *BearerTokenAuth) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) {
auth, ok := headers["authorization"]
if !ok {
Expand All @@ -192,12 +223,14 @@ func (b *BearerTokenAuth) Authenticate(ctx context.Context, headers map[string][
if !ok || len(auth) == 0 {
return ctx, errors.New("missing or empty authorization header")
}
token := auth[0]
expect := b.authorizationValue()
if subtle.ConstantTimeCompare([]byte(expect), []byte(token)) == 0 {
return ctx, fmt.Errorf("scheme or token does not match: %s", token)
token := auth[0] // Extract token from authorization header
expectedTokens := b.authorizationValues()
for _, expectedToken := range expectedTokens {
if subtle.ConstantTimeCompare([]byte(expectedToken), []byte(token)) == 1 {
return ctx, nil // Authentication successful, token is valid
}
}
return ctx, nil
return ctx, fmt.Errorf("scheme or token does not match: %s", token) // Token is invalid
}

// BearerAuthRoundTripper intercepts and adds Bearer token Authorization headers to each http request.
Expand All @@ -206,7 +239,7 @@ type BearerAuthRoundTripper struct {
auth *BearerTokenAuth
}

// RoundTrip modifies the original request and adds Bearer token Authorization headers.
// RoundTrip modifies the original request and adds Bearer token Authorization headers. Incoming requests support multiple tokens, but outgoing requests only use one.
func (interceptor *BearerAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := req.Clone(req.Context())
if req2.Header == nil {
Expand Down
102 changes: 101 additions & 1 deletion extension/bearertokenauthextension/bearertokenauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/stretchr/testify/assert"
"go.opentelemetry.io/collector/component/componenttest"
"go.opentelemetry.io/collector/config/configopaque"
"go.uber.org/zap/zaptest"
)

Expand Down Expand Up @@ -226,7 +227,7 @@ func TestBearerTokenUpdateForGrpc(t *testing.T) {
assert.Equal(t, map[string]string{"authorization": "Bearer " + "1234"}, md)

// update the token
bauth.setAuthorizationValue("5678")
bauth.setAuthorizationValues([]string{"5678"})
md, err = perRPCAuth.GetRequestMetadata(context.Background())
assert.NoError(t, err)
assert.Equal(t, map[string]string{"authorization": "Bearer " + "5678"}, md)
Expand Down Expand Up @@ -284,3 +285,102 @@ func TestBearerServerAuthenticate(t *testing.T) {

assert.NoError(t, bauth.Shutdown(context.Background()))
}

func TestBearerTokenMultipleTokens(t *testing.T) {
cfg := createDefaultConfig().(*Config)
cfg.Scheme = "Bearer"
cfg.Tokens = []configopaque.String{"token1", "token2"}

bauth := newBearerTokenAuth(cfg, zaptest.NewLogger(t))
assert.NotNil(t, bauth)

assert.NoError(t, bauth.Start(context.Background(), componenttest.NewNopHost()))
credential, err := bauth.PerRPCCredentials()
assert.NoError(t, err)
assert.NotNil(t, credential)

md, err := credential.GetRequestMetadata(context.Background())
expectedMd := map[string]string{
"authorization": "Bearer token1",
}
assert.Equal(t, expectedMd, md)
assert.NoError(t, err)
assert.True(t, credential.RequireTransportSecurity())

// Test Authenticate with multiple tokens
headers := map[string][]string{
"authorization": {"Bearer token1"},
}
ctx := context.Background()
newCtx, err := bauth.Authenticate(ctx, headers)
assert.NoError(t, err)
assert.Equal(t, ctx, newCtx)

headers = map[string][]string{
"authorization": {"Bearer token2"},
}
newCtx, err = bauth.Authenticate(ctx, headers)
assert.NoError(t, err)
assert.Equal(t, ctx, newCtx)

headers = map[string][]string{
"authorization": {"Bearer invalidtoken"},
}
_, err = bauth.Authenticate(ctx, headers)
assert.Error(t, err)

assert.NoError(t, bauth.Shutdown(context.Background()))
}

func TestBearerTokenMultipleTokensInFile(t *testing.T) {
scheme := "Bearer"
filename := filepath.Join("testdata", t.Name()+".tokens")
fileContent := "token1\ntoken2"
err := os.WriteFile(filename, []byte(fileContent), 0644)
assert.NoError(t, err)
defer os.Remove(filename)

cfg := createDefaultConfig().(*Config)
cfg.Scheme = scheme
cfg.Filename = filename

bauth := newBearerTokenAuth(cfg, zaptest.NewLogger(t))
assert.NotNil(t, bauth)

assert.NoError(t, bauth.Start(context.Background(), componenttest.NewNopHost()))
credential, err := bauth.PerRPCCredentials()
assert.NoError(t, err)
assert.NotNil(t, credential)

md, err := credential.GetRequestMetadata(context.Background())
expectedMd := map[string]string{
"authorization": "Bearer token1",
}
assert.Equal(t, expectedMd, md)
assert.NoError(t, err)
assert.True(t, credential.RequireTransportSecurity())

// Test Authenticate with multiple tokens
headers := map[string][]string{
"authorization": {"Bearer token1"},
}
ctx := context.Background()
newCtx, err := bauth.Authenticate(ctx, headers)
assert.NoError(t, err)
assert.Equal(t, ctx, newCtx)

headers = map[string][]string{
"authorization": {"Bearer token2"},
}
newCtx, err = bauth.Authenticate(ctx, headers)
assert.NoError(t, err)
assert.Equal(t, ctx, newCtx)

headers = map[string][]string{
"authorization": {"Bearer invalidtoken"},
}
_, err = bauth.Authenticate(ctx, headers)
assert.Error(t, err)

assert.NoError(t, bauth.Shutdown(context.Background()))
}
11 changes: 9 additions & 2 deletions extension/bearertokenauthextension/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,26 @@ type Config struct {
// BearerToken specifies the bearer token to use for every RPC.
BearerToken configopaque.String `mapstructure:"token,omitempty"`

// Filename points to a file that contains the bearer token to use for every RPC.
// Tokens specifies multiple bearer tokens to use for every RPC.
Tokens []configopaque.String `mapstructure:"tokens,omitempty"`

// Filename points to a file that contains the bearer token(s) to use for every RPC.
Filename string `mapstructure:"filename,omitempty"`
}

var (
_ component.Config = (*Config)(nil)
errNoTokenProvided = errors.New("no bearer token provided")
errTokensAndTokenProvided = errors.New("either tokens or token should be provided, not both")
)

// Validate checks if the extension configuration is valid
func (cfg *Config) Validate() error {
if cfg.BearerToken == "" && cfg.Filename == "" {
if cfg.BearerToken == "" && len(cfg.Tokens) == 0 && cfg.Filename == "" {
return errNoTokenProvided
}
if cfg.BearerToken != "" && len(cfg.Tokens) > 0 {
return errTokensAndTokenProvided
}
return nil
}
40 changes: 40 additions & 0 deletions extension/bearertokenauthextension/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configopaque"
"go.opentelemetry.io/collector/confmap/confmaptest"
"go.opentelemetry.io/collector/confmap/xconfmap"

Expand Down Expand Up @@ -42,6 +43,45 @@ func TestLoadConfig(t *testing.T) {
BearerToken: "my-token",
},
},
{
id: component.NewIDWithName(metadata.Type, "multipletokens"),
expected: &Config{
Scheme: "Bearer",
Tokens: []configopaque.String{"token1", "thistokenalsoworks"},
},
},
{
id: component.NewIDWithName(metadata.Type, "withfilename"),
expected: &Config{
Scheme: "Bearer",
Filename: "file-containing.token",
},
},
{
id: component.NewIDWithName(metadata.Type, "both"),
expected: &Config{
Scheme: "Bearer",
BearerToken: "ignoredtoken",
Filename: "file-containing.token",
},
},
{
id: component.NewIDWithName(metadata.Type, "tokensandtoken"),
expected: &Config{
Scheme: "Bearer",
BearerToken: "sometoken",
Tokens: []configopaque.String{"token1", "thistokenalsoworks"},
},
expectedErr: true,
},
{
id: component.NewIDWithName(metadata.Type, "withtokensandfilename"),
expected: &Config{
Scheme: "Bearer",
Tokens: []configopaque.String{"ignoredtoken1", "ignoredtoken2"},
Filename: "file-containing.token",
},
},
}
for _, tt := range tests {
t.Run(tt.id.String(), func(t *testing.T) {
Expand Down
Loading