feat(tracing): add bearer token authentication for OTLP exporters
Add BearerTokenFunc to support dynamic bearer token authentication for OTLP exporters. Tokens are injected per-request via gRPC PerRPCCredentials and HTTP custom RoundTripper. - Add BearerTokenFunc type and Config field in tracerconfig - Implement bearerCredentials (gRPC) and bearerRoundTripper (HTTP) - Wire bearer auth into all exporter creation functions - Add getHTTPClient helper for DRY HTTP client configuration - Upgrade OpenTelemetry SDK to v1.39.0 for WithHTTPClient support
This commit is contained in:
52
internal/tracerconfig/auth.go
Normal file
52
internal/tracerconfig/auth.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tracerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// bearerCredentials implements gRPC PerRPCCredentials for bearer token authentication.
|
||||
// It is safe for concurrent use as required by the gRPC PerRPCCredentials interface.
|
||||
type bearerCredentials struct {
|
||||
tokenFunc BearerTokenFunc
|
||||
}
|
||||
|
||||
// GetRequestMetadata returns authorization metadata for each RPC call.
|
||||
// It calls the token function to retrieve the current token.
|
||||
func (c *bearerCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
|
||||
token, err := c.tokenFunc(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return nil, nil // Omit header for empty token
|
||||
}
|
||||
return map[string]string{"authorization": "Bearer " + token}, nil
|
||||
}
|
||||
|
||||
// RequireTransportSecurity returns true because bearer tokens require TLS.
|
||||
func (c *bearerCredentials) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// bearerRoundTripper wraps an http.RoundTripper to add bearer token authentication.
|
||||
// It is safe for concurrent use as required by the http.RoundTripper interface.
|
||||
type bearerRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
tokenFunc BearerTokenFunc
|
||||
}
|
||||
|
||||
// RoundTrip adds the Authorization header with the bearer token.
|
||||
func (rt *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
token, err := rt.tokenFunc(req.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
// Clone only when adding a header to preserve the original request
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
326
internal/tracerconfig/auth_test.go
Normal file
326
internal/tracerconfig/auth_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package tracerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBearerCredentials_GetRequestMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenFunc BearerTokenFunc
|
||||
wantMeta map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "test-token-123", nil
|
||||
},
|
||||
wantMeta: map[string]string{"authorization": "Bearer test-token-123"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty token returns nil map",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
wantMeta: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "token function error",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("token retrieval failed")
|
||||
},
|
||||
wantMeta: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &bearerCredentials{tokenFunc: tt.tokenFunc}
|
||||
meta, err := c.GetRequestMetadata(context.Background())
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetRequestMetadata() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantMeta == nil && meta != nil {
|
||||
t.Errorf("GetRequestMetadata() = %v, want nil", meta)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantMeta != nil {
|
||||
if meta == nil {
|
||||
t.Errorf("GetRequestMetadata() = nil, want %v", tt.wantMeta)
|
||||
return
|
||||
}
|
||||
for k, v := range tt.wantMeta {
|
||||
if meta[k] != v {
|
||||
t.Errorf("GetRequestMetadata()[%q] = %q, want %q", k, meta[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerCredentials_GetRequestMetadata_ContextPassed(t *testing.T) {
|
||||
type ctxKey string
|
||||
key := ctxKey("test-key")
|
||||
expectedValue := "test-value"
|
||||
|
||||
var receivedCtx context.Context
|
||||
c := &bearerCredentials{
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
receivedCtx = ctx
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
_, err := c.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if receivedCtx == nil {
|
||||
t.Fatal("context was not passed to tokenFunc")
|
||||
}
|
||||
|
||||
if receivedCtx.Value(key) != expectedValue {
|
||||
t.Errorf("context value = %v, want %v", receivedCtx.Value(key), expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerCredentials_RequireTransportSecurity(t *testing.T) {
|
||||
c := &bearerCredentials{tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}}
|
||||
|
||||
if !c.RequireTransportSecurity() {
|
||||
t.Error("RequireTransportSecurity() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenFunc BearerTokenFunc
|
||||
wantAuthHeader string
|
||||
wantErr bool
|
||||
serverShouldRun bool
|
||||
}{
|
||||
{
|
||||
name: "adds authorization header with valid token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "test-token-abc", nil
|
||||
},
|
||||
wantAuthHeader: "Bearer test-token-abc",
|
||||
wantErr: false,
|
||||
serverShouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "omits header for empty token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
wantAuthHeader: "",
|
||||
wantErr: false,
|
||||
serverShouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "propagates token function errors",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("token error")
|
||||
},
|
||||
wantAuthHeader: "",
|
||||
wantErr: true,
|
||||
serverShouldRun: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var receivedAuthHeader string
|
||||
serverCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverCalled = true
|
||||
receivedAuthHeader = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: tt.tokenFunc,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("RoundTrip() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.serverShouldRun {
|
||||
if !serverCalled {
|
||||
t.Error("expected server to be called but it wasn't")
|
||||
return
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if serverCalled && receivedAuthHeader != tt.wantAuthHeader {
|
||||
t.Errorf("Authorization header = %q, want %q", receivedAuthHeader, tt.wantAuthHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_PreservesOriginalRequest(t *testing.T) {
|
||||
originalHeader := "original-value"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "new-token", nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Custom", originalHeader)
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Original request should not be modified
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("original request Authorization header was modified to %q", auth)
|
||||
}
|
||||
|
||||
if custom := req.Header.Get("X-Custom"); custom != originalHeader {
|
||||
t.Errorf("original request X-Custom header = %q, want %q", custom, originalHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_UsesRequestContext(t *testing.T) {
|
||||
type ctxKey string
|
||||
key := ctxKey("test-key")
|
||||
expectedValue := "context-value"
|
||||
|
||||
var receivedCtx context.Context
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
receivedCtx = ctx
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedCtx == nil {
|
||||
t.Fatal("context was not passed to tokenFunc")
|
||||
}
|
||||
|
||||
if receivedCtx.Value(key) != expectedValue {
|
||||
t.Errorf("context value = %v, want %v", receivedCtx.Value(key), expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_PreservesRequestBody(t *testing.T) {
|
||||
expectedBody := "request body content"
|
||||
var receivedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "POST", server.URL,
|
||||
io.NopCloser(newStringReader(expectedBody)))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.ContentLength = int64(len(expectedBody))
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBody != expectedBody {
|
||||
t.Errorf("body = %q, want %q", receivedBody, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
// stringReader is an io.Reader that reads from a string exactly once
|
||||
type stringReader struct {
|
||||
s string
|
||||
done bool
|
||||
}
|
||||
|
||||
func newStringReader(s string) *stringReader {
|
||||
return &stringReader{s: s}
|
||||
}
|
||||
|
||||
func (r *stringReader) Read(p []byte) (n int, err error) {
|
||||
if r.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, r.s)
|
||||
r.done = true
|
||||
return n, io.EOF
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
@@ -109,6 +111,19 @@ func ValidateAndStore(ctx context.Context, cfg *Config, logFactory LogExporterFa
|
||||
// client certificate authentication.
|
||||
type GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
|
||||
|
||||
// BearerTokenFunc retrieves a bearer token for OTLP authentication.
|
||||
// It is called for each export request (traces, logs, metrics).
|
||||
// The caller is responsible for caching and token renewal.
|
||||
// Returns the token string (without "Bearer " prefix) or an error.
|
||||
// An empty string with no error means skip the Authorization header.
|
||||
//
|
||||
// Thread safety: This function may be called concurrently from multiple
|
||||
// goroutines. Implementations must be safe for concurrent use.
|
||||
//
|
||||
// Protocol support: Bearer authentication is supported for both gRPC
|
||||
// (via PerRPCCredentials) and HTTP (via custom RoundTripper) exporters.
|
||||
type BearerTokenFunc func(ctx context.Context) (string, error)
|
||||
|
||||
// Config provides configuration options for OpenTelemetry tracing setup.
|
||||
// It supplements standard OpenTelemetry environment variables with additional
|
||||
// NTP Pool-specific configuration including TLS settings for secure OTLP export.
|
||||
@@ -119,6 +134,7 @@ type Config struct {
|
||||
EndpointURL string // Complete OTLP endpoint URL (e.g., "https://otlp.example.com:4317/v1/traces")
|
||||
CertificateProvider GetClientCertificate // Client certificate provider for mutual TLS
|
||||
RootCAs *x509.CertPool // CA certificate pool for server verification
|
||||
BearerTokenFunc BearerTokenFunc // Token provider for bearer authentication
|
||||
}
|
||||
|
||||
// LogExporterFactory creates an OTLP log exporter using the provided configuration.
|
||||
@@ -229,6 +245,27 @@ func getProtocol(signalSpecificEnv string) string {
|
||||
return proto
|
||||
}
|
||||
|
||||
// getHTTPClient builds an HTTP client with bearer auth and/or TLS if configured.
|
||||
// Returns nil if neither is configured, allowing the SDK to use its defaults.
|
||||
func getHTTPClient(cfg *Config, tlsConfig *tls.Config) *http.Client {
|
||||
if cfg.BearerTokenFunc == nil && tlsConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var transport http.RoundTripper = http.DefaultTransport
|
||||
if tlsConfig != nil {
|
||||
transport = &http.Transport{TLSClientConfig: tlsConfig}
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
transport = &bearerRoundTripper{
|
||||
base: transport,
|
||||
tokenFunc: cfg.BearerTokenFunc,
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// CreateOTLPLogExporter creates an OTLP log exporter using the provided configuration.
|
||||
func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, error) {
|
||||
tlsConfig := getTLSConfig(cfg)
|
||||
@@ -242,6 +279,10 @@ func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, e
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploggrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlploggrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploggrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -254,8 +295,8 @@ func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, e
|
||||
opts := []otlploghttp.Option{
|
||||
otlploghttp.WithCompression(otlploghttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploghttp.WithTLSClientConfig(tlsConfig))
|
||||
if client := getHTTPClient(cfg, tlsConfig); client != nil {
|
||||
opts = append(opts, otlploghttp.WithHTTPClient(client))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploghttp.WithEndpoint(cfg.Endpoint))
|
||||
@@ -290,6 +331,10 @@ func CreateOTLPMetricExporter(ctx context.Context, cfg *Config) (sdkmetric.Expor
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlpmetricgrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -302,8 +347,8 @@ func CreateOTLPMetricExporter(ctx context.Context, cfg *Config) (sdkmetric.Expor
|
||||
opts := []otlpmetrichttp.Option{
|
||||
otlpmetrichttp.WithCompression(otlpmetrichttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetrichttp.WithTLSClientConfig(tlsConfig))
|
||||
if client := getHTTPClient(cfg, tlsConfig); client != nil {
|
||||
opts = append(opts, otlpmetrichttp.WithHTTPClient(client))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetrichttp.WithEndpoint(cfg.Endpoint))
|
||||
@@ -340,6 +385,10 @@ func CreateOTLPTraceExporter(ctx context.Context, cfg *Config) (sdktrace.SpanExp
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlptracegrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracegrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -352,8 +401,8 @@ func CreateOTLPTraceExporter(ctx context.Context, cfg *Config) (sdktrace.SpanExp
|
||||
opts := []otlptracehttp.Option{
|
||||
otlptracehttp.WithCompression(otlptracehttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracehttp.WithTLSClientConfig(tlsConfig))
|
||||
if httpClient := getHTTPClient(cfg, tlsConfig); httpClient != nil {
|
||||
opts = append(opts, otlptracehttp.WithHTTPClient(httpClient))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint))
|
||||
|
||||
Reference in New Issue
Block a user