diff --git a/go.mod b/go.mod index 07453a1..84530f1 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module go.riptides.io/tokenex -go 1.24.4 - -toolchain go1.24.6 +go 1.25.0 require ( cloud.google.com/go/iam v1.5.2 @@ -28,7 +26,10 @@ require ( sigs.k8s.io/controller-runtime v0.21.0 ) -require github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect +) require ( cloud.google.com/go/auth v0.16.2 // indirect @@ -44,7 +45,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect - github.com/aws/smithy-go v1.22.4 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -63,7 +64,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -86,12 +87,12 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect diff --git a/go.sum b/go.sum index 7d8ec54..70fb994 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQj github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= -github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= -github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= @@ -289,8 +289,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= @@ -329,28 +329,28 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= diff --git a/pkg/aws/creds.go b/pkg/aws/creds.go index d5f9f39..edfd9f6 100644 --- a/pkg/aws/creds.go +++ b/pkg/aws/creds.go @@ -13,9 +13,13 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -26,6 +30,8 @@ type credentialsConfig struct { roleSessionName string durationSeconds *int32 identityTokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider } // CredentialsProvider defines the interface for obtaining AWS credentials. @@ -72,13 +78,61 @@ type Provider interface { func (cp *credentialsProvider) isAWS() {} +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, provider *stscreds.WebIdentityRoleProvider, retriever *tokenRetriever) (aws.Credentials, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + // we need to store current ctx as provider.Retrieve(fetchCtx) invoked bellow + // doesn't pass the fetch to TokenRetriever.GetIdentityToken() + retriever.ctx.Store(fetchCtx) + + awsCreds, err := provider.Retrieve(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "failed to retrieve credentials") + tokenextelemetry.RecordResult(span, err) + + return aws.Credentials{}, err + } + + span.SetAttributes(fetchSpanResultAttrs(awsCreds)...) + tokenextelemetry.RecordResult(span, nil) + + return awsCreds, nil +} + // refreshCredentialsLoop handles the credential retrieval and refresh loop. -func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, provider *stscreds.WebIdentityRoleProvider, credsChan chan credential.Result) { +func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + + // Create WebIdentityRoleProvider options + providerOpts := []func(*stscreds.WebIdentityRoleOptions){ + func(o *stscreds.WebIdentityRoleOptions) { + o.RoleSessionName = cfg.roleSessionName + if cfg.durationSeconds != nil { + o.Duration = time.Duration(*cfg.durationSeconds) * time.Second + } + }, + } + + // Create the WebIdentityRoleProvider + retriever := &tokenRetriever{ + provider: cfg.identityTokenProvider, + } + + provider := stscreds.NewWebIdentityRoleProvider( + cp.stsClient, + cfg.roleArn, + retriever, + providerOpts..., + ) + + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, cp.stsClient.Options().Region, correlationID) + for { - // Get credentials - awsCreds, err := provider.Retrieve(ctx) + awsCreds, err := fetchCredentials(ctx, tracer, configAttrs, provider, retriever) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to retrieve credentials")) + util.SendErrorToChannel(credsChan, err) return } @@ -210,29 +264,11 @@ func (cp *credentialsProvider) GetCredentials(ctx context.Context, tokenProvider return nil, errors.WrapIf(err, "failed to get initial ID token") } - // Create WebIdentityRoleProvider options - providerOpts := []func(*stscreds.WebIdentityRoleOptions){ - func(o *stscreds.WebIdentityRoleOptions) { - o.RoleSessionName = cfg.roleSessionName - if cfg.durationSeconds != nil { - o.Duration = time.Duration(*cfg.durationSeconds) * time.Second - } - }, - } - - // Create the WebIdentityRoleProvider - provider := stscreds.NewWebIdentityRoleProvider( - cp.stsClient, - cfg.roleArn, - &tokenRetriever{provider: tokenProvider, ctx: ctx}, - providerOpts..., - ) - credsChan := make(chan credential.Result, 1) go func() { defer close(credsChan) - cp.refreshCredentialsLoop(ctx, provider, credsChan) + cp.refreshCredentialsLoop(ctx, cfg, credsChan) }() return credsChan, nil diff --git a/pkg/aws/option.go b/pkg/aws/option.go index 641924f..26dabb0 100644 --- a/pkg/aws/option.go +++ b/pkg/aws/option.go @@ -4,6 +4,8 @@ package aws import ( + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -71,3 +73,11 @@ func WithIdentityTokenProvider(idtp token.IdentityTokenProvider) option.Option { c.identityTokenProvider = idtp }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/aws/telemetry.go b/pkg/aws/telemetry.go new file mode 100644 index 0000000..562c69f --- /dev/null +++ b/pkg/aws/telemetry.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package aws + +import ( + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/aws" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, region string, correlationID string) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("cfg.role_arn", cfg.roleArn), + attribute.String("cfg.role_session_name", cfg.roleSessionName), + attribute.String("correlation_id", correlationID), + } + if cfg.durationSeconds != nil { + attrs = append(attrs, attribute.Int64("cfg.duration_seconds", int64(*cfg.durationSeconds))) + } + if region != "" { + attrs = append(attrs, attribute.String("cfg.region", region)) + } + + return attrs +} + +func fetchSpanResultAttrs(awsCreds aws.Credentials) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.Bool("credential.expires", awsCreds.CanExpire), + attribute.String("credential.aws.source", awsCreds.Source), + } + if awsCreds.AccountID != "" { + attrs = append(attrs, attribute.String("credential.aws.account_id", awsCreds.AccountID)) + } + if awsCreds.CanExpire { + attrs = append(attrs, attribute.String("credential.expires_at", awsCreds.Expires.UTC().Format(time.RFC3339))) + } + + return attrs +} diff --git a/pkg/aws/token_retriever.go b/pkg/aws/token_retriever.go index 343056e..099c0bd 100644 --- a/pkg/aws/token_retriever.go +++ b/pkg/aws/token_retriever.go @@ -4,28 +4,33 @@ package aws import ( - "context" - "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "go.opentelemetry.io/otel/trace" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" + "go.riptides.io/tokenex/pkg/util" ) // tokenRetriever implements stscreds.IdentityTokenRetriever. type tokenRetriever struct { provider token.IdentityTokenProvider - //nolint: containedctx - ctx context.Context + + ctx util.ContextHolder } // GetIdentityToken returns the token from the provider after validating it. func (t *tokenRetriever) GetIdentityToken() ([]byte, error) { - token, err := t.provider.GetToken(t.ctx) + ctx := t.ctx.Context() + + idToken, err := t.provider.GetToken(ctx) if err != nil { return nil, err } - return []byte(token.Token), nil + trace.SpanFromContext(ctx).SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + + return []byte(idToken.Token), nil } // Ensure tokenRetriever implements the interface. diff --git a/pkg/azure/creds.go b/pkg/azure/creds.go index 95469db..623bae2 100644 --- a/pkg/azure/creds.go +++ b/pkg/azure/creds.go @@ -8,12 +8,17 @@ import ( "time" "emperror.dev/errors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -24,6 +29,8 @@ type credentialsConfig struct { clientID string scope string identityTokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider } // CredentialsProvider defines the interface for obtaining Azure credentials. @@ -73,6 +80,27 @@ type Provider interface { func (cp *credentialsProvider) isAzure() {} +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, cred *azidentity.ClientAssertionCredential, cfg *credentialsConfig) (azcore.AccessToken, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + tok, err := cred.GetToken(fetchCtx, policy.TokenRequestOptions{ + TenantID: cfg.tenantID, + Scopes: []string{cfg.scope}, + }) + if err != nil { + err = errors.WrapIf(err, "failed to retrieve credentials") + tokenextelemetry.RecordResult(span, err) + + return azcore.AccessToken{}, err + } + + span.SetAttributes(fetchSpanResultAttrs(tok)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil +} + // refreshCredentialsLoop handles the credential retrieval and refresh loop. func (cp *credentialsProvider) refreshCredentialsLoop( ctx context.Context, @@ -80,48 +108,49 @@ func (cp *credentialsProvider) refreshCredentialsLoop( cred *azidentity.ClientAssertionCredential, credsChan chan credential.Result, ) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) + for { - token, err := cred.GetToken(ctx, policy.TokenRequestOptions{ - TenantID: cfg.tenantID, - Scopes: []string{cfg.scope}, - }) + tok, err := fetchCredentials(ctx, tracer, configAttrs, cred, cfg) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to retrieve credentials")) + util.SendErrorToChannel(credsChan, err) return } // Calculate when to refresh - timeUntilExpiry := time.Until(token.ExpiresOn) + timeUntilExpiry := time.Until(tok.ExpiresOn) // If credentials are already expired, this is an error if timeUntilExpiry <= 0 { - util.SendErrorToChannel(credsChan, errors.NewWithDetails("received already expired credentials", "expiresAt", token.ExpiresOn)) + util.SendErrorToChannel(credsChan, errors.NewWithDetails("received already expired credentials", "expiresAt", tok.ExpiresOn)) return } // Send credentials azureCredential := &credential.Oauth2Creds{ - AccessToken: token.Token, + AccessToken: tok.Token, TokenType: "Bearer", - Expiry: token.ExpiresOn, + Expiry: tok.ExpiresOn, } util.SendToChannel(credsChan, credential.Result{ Credential: azureCredential, Err: nil, Event: credential.UpdateEventType, }) - cp.logger.V(2).Info("Sent credentials", "expires", token.ExpiresOn) + cp.logger.V(2).Info("Sent credentials", "expires", tok.ExpiresOn) refreshBuffer := util.CalculateRefreshBuffer(timeUntilExpiry) refreshTime := timeUntilExpiry - refreshBuffer - if !token.RefreshOn.IsZero() { + if !tok.RefreshOn.IsZero() { // if refresh time is recommended in the received token, use that - cp.logger.V(2).Info("Using RefreshOn time from token", "refreshOn", token.RefreshOn) + cp.logger.V(2).Info("Using RefreshOn time from token", "refreshOn", tok.RefreshOn) - rt := time.Until(token.RefreshOn) + rt := time.Until(tok.RefreshOn) if rt > 0 { refreshTime = rt } else { @@ -217,12 +246,14 @@ func (cp *credentialsProvider) GetCredentials( cfg.tenantID, cfg.clientID, func(ctx context.Context) (string, error) { - t, err := tokenProvider.GetToken(ctx) + idToken, err := tokenProvider.GetToken(ctx) if err != nil { return "", err } - return t.Token, nil + trace.SpanFromContext(ctx).SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + + return idToken.Token, nil }, nil, ) diff --git a/pkg/azure/option.go b/pkg/azure/option.go index c78f4b3..7ecfc8c 100644 --- a/pkg/azure/option.go +++ b/pkg/azure/option.go @@ -4,6 +4,8 @@ package azure import ( + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -79,3 +81,11 @@ func WithIdentityTokenProvider(idtp token.IdentityTokenProvider) option.Option { c.identityTokenProvider = idtp }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/azure/telemetry.go b/pkg/azure/telemetry.go new file mode 100644 index 0000000..fa662d7 --- /dev/null +++ b/pkg/azure/telemetry.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package azure + +import ( + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/azure" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.tenant_id", cfg.tenantID), + attribute.String("cfg.client_id", cfg.clientID), + attribute.String("cfg.scope", cfg.scope), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanResultAttrs(tok azcore.AccessToken) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.ExpiresOn.UTC().Format(time.RFC3339)), + } +} diff --git a/pkg/gcp/creds.go b/pkg/gcp/creds.go index 22a04ce..558211f 100644 --- a/pkg/gcp/creds.go +++ b/pkg/gcp/creds.go @@ -10,12 +10,16 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" google_option "google.golang.org/api/option" stsv1 "google.golang.org/api/sts/v1" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -27,6 +31,8 @@ type credentialsConfig struct { scopes []string // Scope for the access token, defaults to ["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email"] tokenLifetime *int64 // Lifetime in seconds for the access token of the impersonated service account, optional. If set it should be less than or equal to 1 hour identityTokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider } // CredentialsProvider defines the interface for obtaining GCP credentials. @@ -81,16 +87,46 @@ func validateConfig(cfg *credentialsConfig) error { return nil } +func fetchAccessToken( + ctx context.Context, + tracer trace.Tracer, + configAttrs []attribute.KeyValue, + genAccessTokenFunc func(ctx context.Context) (*oauth2.Token, error), + impersonated bool, +) (*oauth2.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + accessToken, err := genAccessTokenFunc(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "failed to get access token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs(accessToken, impersonated)...) + tokenextelemetry.RecordResult(span, nil) + + return accessToken, nil +} + // refreshCredentialsLoop handles the credential retrieval and refresh loop. func (cp *credentialsProvider) refreshCredentialsLoop( ctx context.Context, - genAccessTokenFunc func() (*oauth2.Token, error), + cfg *credentialsConfig, + genAccessTokenFunc func(ctx context.Context) (*oauth2.Token, error), credsChan chan credential.Result, + tracer trace.Tracer, ) { + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) + impersonated := cfg.serviceAccountEmail != "" + for { - accessToken, err := genAccessTokenFunc() + accessToken, err := fetchAccessToken(ctx, tracer, configAttrs, genAccessTokenFunc, impersonated) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to get access token")) + util.SendErrorToChannel(credsChan, err) return } @@ -235,32 +271,28 @@ func (cp *credentialsProvider) GetCredentials( scope = strings.Join(cfg.scopes, " ") } + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + stsAccessTokenSource := &stsAccessTokenSource{ stsService: cp.stsService, idTokenProvider: tokenProvider, audience: cfg.audience, scope: scope, - ctx: ctx, + tracer: tracer, } - stsAccessToken, err := stsAccessTokenSource.Token() - if err != nil { - return nil, errors.WrapIf(err, "failed to get initial STS access token") - } - - cp.logger.V(2).Info("Initial STS access token retrieved", "expiry", stsAccessToken.Expiry) - - genAccessTokenFunc := func() (*oauth2.Token, error) { + genAccessTokenFunc := func(ctx context.Context) (*oauth2.Token, error) { + stsAccessTokenSource.ctx.Store(ctx) // if no service account impersonation is needed, return the STS access token - return stsAccessTokenSource.Token() + return stsAccessTokenSource.Token() //nolint:contextcheck } if cfg.serviceAccountEmail != "" { - stsAccessToken := oauth2.ReuseTokenSource(stsAccessToken, stsAccessTokenSource) + tokenSource := oauth2.ReuseTokenSource(nil, stsAccessTokenSource) // if service account impersonation is needed, generate access token for the service account - genAccessTokenFunc = func() (*oauth2.Token, error) { - return generateAccessToken(ctx, stsAccessToken, cfg.serviceAccountEmail, cfg.scopes, cfg.tokenLifetime) + genAccessTokenFunc = func(ctx context.Context) (*oauth2.Token, error) { + return generateAccessToken(ctx, tracer, stsAccessTokenSource, tokenSource, cfg.serviceAccountEmail, cfg.scopes, cfg.tokenLifetime) } } @@ -268,7 +300,7 @@ func (cp *credentialsProvider) GetCredentials( go func() { defer close(credsChan) - cp.refreshCredentialsLoop(ctx, genAccessTokenFunc, credsChan) + cp.refreshCredentialsLoop(ctx, cfg, genAccessTokenFunc, credsChan, tracer) }() return credsChan, nil diff --git a/pkg/gcp/iam.go b/pkg/gcp/iam.go index 653c3b8..e438191 100644 --- a/pkg/gcp/iam.go +++ b/pkg/gcp/iam.go @@ -5,27 +5,55 @@ package gcp import ( "context" + "time" credentials "cloud.google.com/go/iam/credentials/apiv1" "cloud.google.com/go/iam/credentials/apiv1/credentialspb" "emperror.dev/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "google.golang.org/api/option" durationpb "google.golang.org/protobuf/types/known/durationpb" + + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" ) -// generateAccessToken generates an access token that impersonates a service account. +const iamImpersonateSpanName = "impersonate_service_account" + +func iamImpersonateSpanConfigAttrs(serviceAccountEmail string, scopes []string, lifetime *int64) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("cfg.service_account_email", serviceAccountEmail), + attribute.StringSlice("cfg.scopes", scopes), + } + if lifetime != nil { + attrs = append(attrs, attribute.Int64("cfg.token_lifetime_seconds", *lifetime)) + } + + return attrs +} + func generateAccessToken( ctx context.Context, - stsAccessTokenSource oauth2.TokenSource, + tracer trace.Tracer, + stsSource *stsAccessTokenSource, + tokenSource oauth2.TokenSource, serviceAccountEmail string, scopes []string, lifetime *int64, ) (*oauth2.Token, error) { + ctx, span := tracer.Start(ctx, iamImpersonateSpanName, trace.WithAttributes(iamImpersonateSpanConfigAttrs(serviceAccountEmail, scopes, lifetime)...)) + defer span.End() + + stsSource.ctx.Store(ctx) + // Create the IAM Credentials API client using the STS token - iamClient, err := credentials.NewIamCredentialsClient(ctx, option.WithTokenSource(stsAccessTokenSource)) + iamClient, err := credentials.NewIamCredentialsClient(ctx, option.WithTokenSource(tokenSource)) if err != nil { - return nil, errors.WrapIf(err, "failed to create IAM credentials client") + err = errors.WrapIf(err, "failed to create IAM credentials client") + tokenextelemetry.RecordResult(span, err) + + return nil, err } defer iamClient.Close() @@ -45,12 +73,23 @@ func generateAccessToken( // Generate access token resp, err := iamClient.GenerateAccessToken(ctx, req) if err != nil { - return nil, errors.WrapIf(err, "failed to generate access token") + err = errors.WrapIf(err, "failed to generate access token") + tokenextelemetry.RecordResult(span, err) + + return nil, err } - return &oauth2.Token{ + tok := &oauth2.Token{ AccessToken: resp.GetAccessToken(), TokenType: "Bearer", Expiry: resp.GetExpireTime().AsTime(), - }, nil + } + + span.SetAttributes( + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339)), + ) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil } diff --git a/pkg/gcp/option.go b/pkg/gcp/option.go index 8463183..b7c22bd 100644 --- a/pkg/gcp/option.go +++ b/pkg/gcp/option.go @@ -4,6 +4,8 @@ package gcp import ( + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -74,3 +76,11 @@ func WithIdentityTokenProvider(idtp token.IdentityTokenProvider) option.Option { c.identityTokenProvider = idtp }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/gcp/sts.go b/pkg/gcp/sts.go index 2408a9d..d5b6cf4 100644 --- a/pkg/gcp/sts.go +++ b/pkg/gcp/sts.go @@ -4,33 +4,49 @@ package gcp import ( - "context" "time" "emperror.dev/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" stsv1 "google.golang.org/api/sts/v1" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" + "go.riptides.io/tokenex/pkg/util" ) -type stsAccessTokenSource struct { - oauth2.TokenSource +const stsExchangeSpanName = "exchange_token" +type stsAccessTokenSource struct { stsService *stsv1.Service idTokenProvider token.IdentityTokenProvider audience string scope string - ctx context.Context //nolint:containedctx + tracer trace.Tracer + + ctx util.ContextHolder } func (s *stsAccessTokenSource) Token() (*oauth2.Token, error) { - idToken, err := s.idTokenProvider.GetToken(s.ctx) + ctx, span := s.tracer.Start(s.ctx.Context(), stsExchangeSpanName, trace.WithAttributes( + attribute.String("cfg.audience", s.audience), + attribute.String("cfg.scope", s.scope), + )) + defer span.End() + + idToken, err := s.idTokenProvider.GetToken(ctx) if err != nil { - return nil, errors.WrapIf(err, "failed to get ID token") + err = errors.WrapIf(err, "failed to get ID token") + tokenextelemetry.RecordResult(span, err) + + return nil, err } + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + // exchange ID token for STS token req := &stsv1.GoogleIdentityStsV1ExchangeTokenRequest{ Audience: s.audience, @@ -41,15 +57,23 @@ func (s *stsAccessTokenSource) Token() (*oauth2.Token, error) { SubjectToken: idToken.Token, } - resp, err := s.stsService.V1.Token(req).Context(s.ctx).Do() + resp, err := s.stsService.V1.Token(req).Context(ctx).Do() if err != nil { - return nil, errors.WrapIf(err, "failed to exchange ID token for STS token") + err = errors.WrapIf(err, "failed to exchange ID token for STS token") + tokenextelemetry.RecordResult(span, err) + + return nil, err } - return &oauth2.Token{ + tok := &oauth2.Token{ AccessToken: resp.AccessToken, TokenType: "Bearer", ExpiresIn: resp.ExpiresIn, Expiry: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second), - }, nil + } + + span.SetAttributes(attribute.String("sts.access_token.expires_at", tok.Expiry.UTC().Format(time.RFC3339))) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil } diff --git a/pkg/gcp/telemetry.go b/pkg/gcp/telemetry.go new file mode 100644 index 0000000..3428300 --- /dev/null +++ b/pkg/gcp/telemetry.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package gcp + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/gcp" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("cfg.audience", cfg.audience), + attribute.StringSlice("cfg.scopes", cfg.scopes), + attribute.String("correlation_id", correlationID), + } + if cfg.serviceAccountEmail != "" { + attrs = append(attrs, attribute.String("cfg.service_account_email", cfg.serviceAccountEmail)) + } + if cfg.tokenLifetime != nil { + attrs = append(attrs, attribute.Int64("cfg.token_lifetime_seconds", *cfg.tokenLifetime)) + } + + return attrs +} + +func fetchSpanResultAttrs(tok *oauth2.Token, impersonated bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339)), + attribute.Bool("credential.service_account_impersonation", impersonated), + } +} diff --git a/pkg/generic/creds.go b/pkg/generic/creds.go index 23d1763..fe6fee0 100644 --- a/pkg/generic/creds.go +++ b/pkg/generic/creds.go @@ -9,9 +9,13 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -38,6 +42,8 @@ var _ CredentialsProvider = &credentialsProvider{} type credentialsConfig struct { tokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider } type credentialsProvider struct { @@ -125,19 +131,43 @@ func (cp *credentialsProvider) GetCredentials( go func() { defer close(credsChan) - cp.refreshCredentialsLoop(ctx, cfg.tokenProvider, credsChan, opts...) + cp.refreshCredentialsLoop(ctx, cfg.tokenProvider, cfg.tracerProvider, credsChan, opts...) }() return credsChan, nil } +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, tokenProvider token.IdentityTokenProvider, opts ...option.Option) (credential.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + tok, err := tokenProvider.GetToken(fetchCtx, opts...) + if err != nil { + err = errors.WrapIf(err, "could not create token") + tokenextelemetry.RecordResult(span, err) + + return credential.Token{}, err + } + + span.SetAttributes(fetchSpanResultAttrs(tok)...) + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", tok.Token, tok.ExpiresAt)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil +} + // refreshCredentialsLoop handles the credential retrieval and refresh loop. func (cp *credentialsProvider) refreshCredentialsLoop( ctx context.Context, tokenProvider token.IdentityTokenProvider, + tracerProvider trace.TracerProvider, credsChan chan credential.Result, opts ...option.Option, ) { + tracer := tokenextelemetry.Tracer(ctx, tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(opts, correlationID) + var err error var token credential.Token @@ -157,9 +187,9 @@ loop: } } - token, err = tokenProvider.GetToken(ctx, opts...) + token, err = fetchCredentials(ctx, tracer, configAttrs, tokenProvider, opts...) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not create token")) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/generic/option.go b/pkg/generic/option.go index 2c51b55..4be8f35 100644 --- a/pkg/generic/option.go +++ b/pkg/generic/option.go @@ -6,6 +6,8 @@ package generic import ( "time" + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -45,6 +47,14 @@ func WithIdentityTokenProvider(tokenProvider token.IdentityTokenProvider) option }) } +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} + var ( audiencesOptionID = option.NewOptionID("audiences") claimsOptionID = option.NewOptionID("claims") diff --git a/pkg/generic/telemetry.go b/pkg/generic/telemetry.go new file mode 100644 index 0000000..99ca268 --- /dev/null +++ b/pkg/generic/telemetry.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package generic + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/generic" + +func fetchSpanConfigAttrs(opts []option.Option, correlationID string) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("correlation_id", correlationID), + } + + for _, opt := range opts { + if audiences, ok := IsAudiencesOption(opt); ok { + attrs = append(attrs, attribute.StringSlice("cfg.audiences", audiences)) + } + if lifetime, ok := IsLifetimeOption(opt); ok { + attrs = append(attrs, attribute.String("cfg.lifetime", lifetime.String())) + } + } + + return attrs +} + +func fetchSpanResultAttrs(tok credential.Token) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.Bool("credential.expires", !tok.ExpiresAt.IsZero()), + } + if !tok.ExpiresAt.IsZero() { + attrs = append(attrs, attribute.String("credential.expires_at", tok.ExpiresAt.UTC().Format(time.RFC3339))) + } + + return attrs +} diff --git a/pkg/githubapp/githubapp.go b/pkg/githubapp/githubapp.go index a825a4e..14f1a4c 100644 --- a/pkg/githubapp/githubapp.go +++ b/pkg/githubapp/githubapp.go @@ -17,9 +17,13 @@ import ( "github.com/go-logr/logr" "github.com/golang-jwt/jwt/v5" "github.com/google/go-github/v66/github" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) @@ -37,6 +41,8 @@ type credentialsConfig struct { baseURL string httpClient *http.Client + + tracerProvider trace.TracerProvider } func setDefaults(cfg *credentialsConfig) { @@ -255,6 +261,24 @@ func (cp *credentialsProvider) GetCredentials(ctx context.Context, opts ...optio return credsChan, nil } +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, client *github.Client, cfg *credentialsConfig, instTokenOpts *github.InstallationTokenOptions) (*github.InstallationToken, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + tok, _, err := client.Apps.CreateInstallationToken(fetchCtx, cfg.installationID, instTokenOpts) + if err != nil { + err = errors.WrapIfWithDetails(err, "failed to mint installation token", "appID", cfg.appID, "installationID", cfg.installationID) + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs(tok)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil +} + func (cp *credentialsProvider) refreshLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { logger := cp.logger.WithValues("appID", cfg.appID, "installationID", cfg.installationID) @@ -271,10 +295,14 @@ func (cp *credentialsProvider) refreshLoop(ctx context.Context, cfg *credentials Permissions: cfg.permissions, } + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) + for { - tok, _, err := client.Apps.CreateInstallationToken(ctx, cfg.installationID, opts) + tok, err := fetchCredentials(ctx, tracer, configAttrs, client, cfg, opts) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIfWithDetails(err, "failed to mint installation token", "appID", cfg.appID, "installationID", cfg.installationID)) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/githubapp/option.go b/pkg/githubapp/option.go index 7669801..63cf071 100644 --- a/pkg/githubapp/option.go +++ b/pkg/githubapp/option.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/google/go-github/v66/github" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/option" ) @@ -107,3 +108,11 @@ func WithHTTPClient(client *http.Client) option.Option { c.httpClient = client }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/githubapp/telemetry.go b/pkg/githubapp/telemetry.go new file mode 100644 index 0000000..4803450 --- /dev/null +++ b/pkg/githubapp/telemetry.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package githubapp + +import ( + "time" + + "github.com/google/go-github/v66/github" + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/githubapp" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Int64("cfg.app_id", cfg.appID), + attribute.Int64("cfg.installation_id", cfg.installationID), + attribute.String("cfg.base_url", cfg.baseURL), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanResultAttrs(tok *github.InstallationToken) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.GetExpiresAt().Time.UTC().Format(time.RFC3339)), + } +} diff --git a/pkg/k8ssecret/k8ssecret.go b/pkg/k8ssecret/k8ssecret.go index 946d31e..a24a853 100644 --- a/pkg/k8ssecret/k8ssecret.go +++ b/pkg/k8ssecret/k8ssecret.go @@ -8,6 +8,9 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" corev1 "k8s.io/api/core/v1" toolscache "k8s.io/client-go/tools/cache" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -15,6 +18,7 @@ import ( "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) @@ -55,6 +59,8 @@ var _ CredentialsProvider = &credentialsProvider{} type credentialsConfig struct { secretRef SecretRef + + tracerProvider trace.TracerProvider } type credentialsProvider struct { @@ -112,13 +118,21 @@ func (cp *credentialsProvider) GetCredentialsWithOptions(ctx context.Context, op return nil, err } - return cp.GetCredentials(ctx, cfg.secretRef) + return cp.getCredentials(ctx, cfg) } func (cp *credentialsProvider) GetCredentials( ctx context.Context, secretRef SecretRef, ) (<-chan Credential, error) { + return cp.getCredentials(ctx, &credentialsConfig{secretRef: secretRef}) +} + +func (cp *credentialsProvider) getCredentials(ctx context.Context, cfg *credentialsConfig) (<-chan Credential, error) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := spanConfigAttrs(cfg.secretRef, correlationID) + informer, err := cp.cache.GetInformer(ctx, &corev1.Secret{}, cache.BlockUntilSynced(true)) if err != nil { return nil, errors.WrapIf(err, "could not get informer") @@ -128,13 +142,13 @@ func (cp *credentialsProvider) GetCredentials( handler, err := informer.AddEventHandler(toolscache.ResourceEventHandlerFuncs{ AddFunc: func(obj any) { - cp.handleEvent(credsChan, secretRef, obj, false) + cp.handleEvent(ctx, tracer, configAttrs, credsChan, cfg.secretRef, obj, false) }, UpdateFunc: func(oldObj, newObj any) { - cp.handleEvent(credsChan, secretRef, newObj, false) + cp.handleEvent(ctx, tracer, configAttrs, credsChan, cfg.secretRef, newObj, false) }, DeleteFunc: func(obj any) { - cp.handleEvent(credsChan, secretRef, obj, true) + cp.handleEvent(ctx, tracer, configAttrs, credsChan, cfg.secretRef, obj, true) }, }) if err != nil { @@ -146,11 +160,8 @@ func (cp *credentialsProvider) GetCredentials( } // do initial check of the specified secret - if err := cp.initialValidation(ctx, secretRef); err != nil { - util.SendToChannel(credsChan, Credential{ - Err: err, - Event: credential.UpdateEventType, - }) + if err := cp.initialValidation(ctx, cfg.secretRef); err != nil { + cp.publishFetch(ctx, tracer, configAttrs, credsChan, nil, err) } go func() { @@ -182,7 +193,39 @@ func (cp *credentialsProvider) initialValidation(ctx context.Context, secretRef return nil } -func (cp *credentialsProvider) handleEvent(credsChan chan Credential, secretRef SecretRef, obj any, del bool) { +func (cp *credentialsProvider) publishFetch(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, credsChan chan Credential, tok *credential.Token, fetchErr error) { + _, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + if fetchErr != nil { + tokenextelemetry.RecordResult(span, fetchErr) + util.SendToChannel(credsChan, Credential{ + Err: fetchErr, + Event: credential.UpdateEventType, + }) + + return + } + + span.SetAttributes(fetchSpanResultAttrs()...) + tokenextelemetry.RecordResult(span, nil) + util.SendToChannel(credsChan, Credential{ + Credential: tok, + Event: credential.UpdateEventType, + }) +} + +func (cp *credentialsProvider) publishRemove(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, credsChan chan Credential) { + _, span := tracer.Start(ctx, removeSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + tokenextelemetry.RecordResult(span, nil) + util.SendToChannel(credsChan, Credential{ + Event: credential.RemoveEventType, + }) +} + +func (cp *credentialsProvider) handleEvent(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, credsChan chan Credential, secretRef SecretRef, obj any, del bool) { secret, ok := obj.(*corev1.Secret) if !ok { return @@ -193,27 +236,19 @@ func (cp *credentialsProvider) handleEvent(credsChan chan Credential, secretRef } if del { - util.SendToChannel(credsChan, Credential{ - Event: credential.RemoveEventType, - }) + cp.publishRemove(ctx, tracer, configAttrs, credsChan) return } value, ok := secret.Data[secretRef.Key] if !ok { - util.SendToChannel(credsChan, Credential{ - Err: ErrMissingData, - Event: credential.UpdateEventType, - }) + cp.publishFetch(ctx, tracer, configAttrs, credsChan, nil, ErrMissingData) return } - util.SendToChannel(credsChan, Credential{ - Credential: &credential.Token{ - Token: string(value), - }, - Event: credential.UpdateEventType, - }) + cp.publishFetch(ctx, tracer, configAttrs, credsChan, &credential.Token{ + Token: string(value), + }, nil) } diff --git a/pkg/k8ssecret/option.go b/pkg/k8ssecret/option.go index cb21eb9..c8d4a0c 100644 --- a/pkg/k8ssecret/option.go +++ b/pkg/k8ssecret/option.go @@ -3,7 +3,11 @@ package k8ssecret -import "go.riptides.io/tokenex/pkg/option" +import ( + "go.opentelemetry.io/otel/trace" + + "go.riptides.io/tokenex/pkg/option" +) // Option is a function that modifies the credentialsConfig. type ( @@ -39,3 +43,11 @@ func WithSecretRef(sr SecretRef) option.Option { c.secretRef = sr }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/k8ssecret/telemetry.go b/pkg/k8ssecret/telemetry.go new file mode 100644 index 0000000..60b1000 --- /dev/null +++ b/pkg/k8ssecret/telemetry.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package k8ssecret + +import ( + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const removeSpanName = "credential.removed" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/k8ssecret" + +func spanConfigAttrs(secretRef SecretRef, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.secret_name", secretRef.Name), + attribute.String("cfg.secret_namespace", secretRef.Namespace), + attribute.String("cfg.secret_key", secretRef.Key), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanResultAttrs() []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", false), + } +} diff --git a/pkg/oauth2ac/oauth2ac.go b/pkg/oauth2ac/oauth2ac.go index 39b3cfc..fe31dc2 100644 --- a/pkg/oauth2ac/oauth2ac.go +++ b/pkg/oauth2ac/oauth2ac.go @@ -17,6 +17,7 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" "github.com/werbenhu/eventbus" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" corev1 "k8s.io/api/core/v1" toolscache "k8s.io/client-go/tools/cache" @@ -24,6 +25,7 @@ import ( "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) @@ -150,10 +152,13 @@ type credentialsProvider struct { statusCh chan StatusEvent refreshCh chan struct{} - mu sync.RWMutex - clientID string - clientSecret string - secretError error + mu sync.RWMutex + clientID string + clientSecret string + secretError error + authorizeSpanContext trace.SpanContext + + tracerProvider trace.TracerProvider statesMu sync.RWMutex authStates map[string]authState @@ -287,7 +292,9 @@ func (cp *credentialsProvider) Authorize(ctx context.Context, authState, code st return nil, errors.WithStack(ErrAlreadyAuthorized) } - token, err := cp.exchangeToken(ctx, authState, code) + tracer := tokenextelemetry.Tracer(ctx, cp.tracerProvider, instrumentationScopeName) + + token, sc, err := cp.exchangeToken(ctx, tracer, authState, code) if err != nil { return nil, err } @@ -304,6 +311,10 @@ func (cp *credentialsProvider) Authorize(ctx context.Context, authState, code st return nil, err } + cp.mu.Lock() + cp.authorizeSpanContext = sc + cp.mu.Unlock() + if cp.syncGate.IsOpen() { cp.signalRefresh() } @@ -387,7 +398,15 @@ func (cp *credentialsProvider) sendStatusEvent(event StatusEvent) { } } -func (cp *credentialsProvider) exchangeToken(ctx context.Context, authState string, code string) (*oauth2.Token, error) { +func (cp *credentialsProvider) exchangeToken(ctx context.Context, tracer trace.Tracer, authState string, code string) (*oauth2.Token, trace.SpanContext, error) { + cp.mu.RLock() + clientID := cp.clientID + cp.mu.RUnlock() + + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(exchangeSpanConfigAttrs(cp.cfg, clientID, cp.id)...)) + defer span.End() + sc := span.SpanContext() + opts := []oauth2.AuthCodeOption{} cp.logger.V(2).Info("exchange", "state", authState) @@ -396,7 +415,10 @@ func (cp *credentialsProvider) exchangeToken(ctx context.Context, authState stri st, ok := cp.authStates[authState] cp.statesMu.RUnlock() if !ok { - return nil, errors.WithStack(ErrUnknownAuthState) + err := errors.WithStack(ErrUnknownAuthState) + tokenextelemetry.RecordResult(span, err) + + return nil, sc, err } if cp.cfg.UsePKCE { @@ -413,7 +435,17 @@ func (cp *credentialsProvider) exchangeToken(ctx context.Context, authState stri } } - return st.cfg.Exchange(ctx, code, opts...) + tok, err := st.cfg.Exchange(fetchCtx, code, opts...) + if err != nil { + tokenextelemetry.RecordResult(span, err) + + return nil, sc, err + } + + span.SetAttributes(resultAttrs(tok)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, sc, nil } func (cp *credentialsProvider) storeTokenAndAuthorize(ctx context.Context, token *oauth2.Token) error { @@ -496,7 +528,36 @@ func (cp *credentialsProvider) startInformer(ctx context.Context) (func(), error }, nil } -func (cp *credentialsProvider) getToken(ctx context.Context, forceRefresh bool) (*oauth2.Token, error) { +func (cp *credentialsProvider) fetchToken(ctx context.Context, tracer trace.Tracer, correlationID string, t oauth2.Token) (*oauth2.Token, error) { + cp.mu.RLock() + clientID := cp.clientID + sc := cp.authorizeSpanContext + cp.mu.RUnlock() + + spanOpts := []trace.SpanStartOption{trace.WithAttributes(fetchSpanConfigAttrs(cp.cfg, clientID, correlationID)...)} + if sc.IsValid() { + spanOpts = append(spanOpts, trace.WithLinks(trace.Link{SpanContext: sc})) + } + + fetchCtx, span := tracer.Start(ctx, fetchSpanName, spanOpts...) + defer span.End() + + cfg := cp.oauth2Config() + + accessToken, err := cfg.TokenSource(fetchCtx, &t).Token() + if err != nil { + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(resultAttrs(accessToken)...) + tokenextelemetry.RecordResult(span, nil) + + return accessToken, nil +} + +func (cp *credentialsProvider) getToken(ctx context.Context, tracer trace.Tracer, correlationID string, forceRefresh bool) (*oauth2.Token, error) { accessToken, err := cp.tokenStore.Get(ctx, cp.id) if err != nil { return nil, err @@ -514,8 +575,7 @@ func (cp *credentialsProvider) getToken(ctx context.Context, forceRefresh bool) t := *accessToken t.Expiry = time.Now().Add(-time.Hour) - cfg := cp.oauth2Config() - accessToken, err = cfg.TokenSource(ctx, &t).Token() + accessToken, err = cp.fetchToken(ctx, tracer, correlationID, t) if err != nil { return nil, err } @@ -528,6 +588,9 @@ func (cp *credentialsProvider) getToken(ctx context.Context, forceRefresh bool) } func (cp *credentialsProvider) tokenRefresherLoop(ctx context.Context) { + tracer := tokenextelemetry.Tracer(ctx, cp.tracerProvider, instrumentationScopeName) + correlationID := cp.id + var refreshTime time.Duration for { cp.logger.V(3).Info("wait for authorization") @@ -535,7 +598,7 @@ func (cp *credentialsProvider) tokenRefresherLoop(ctx context.Context) { return } - accessToken, err := cp.getToken(ctx, refreshTime > 0) + accessToken, err := cp.getToken(ctx, tracer, correlationID, refreshTime > 0) if err != nil { cp.logger.Error(err, "could not get token from storage") diff --git a/pkg/oauth2ac/options.go b/pkg/oauth2ac/options.go index 030fcaf..a58a309 100644 --- a/pkg/oauth2ac/options.go +++ b/pkg/oauth2ac/options.go @@ -6,6 +6,8 @@ package oauth2ac import ( "time" + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" ) @@ -61,3 +63,11 @@ func WithAuthStateCleanupInterval(d time.Duration) option.Option { cp.authStateCleanupInterval = d }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsProviderOption(func(cp *credentialsProvider) { + cp.tracerProvider = tracerProvider + }) +} diff --git a/pkg/oauth2ac/telemetry.go b/pkg/oauth2ac/telemetry.go new file mode 100644 index 0000000..d45e2c3 --- /dev/null +++ b/pkg/oauth2ac/telemetry.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package oauth2ac + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/oauth2ac" + +func exchangeSpanConfigAttrs(cfg *CredentialsConfig, clientID string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.grant_type", "authorization_code"), + attribute.String("cfg.authorization_endpoint_url", cfg.AuthorizationEndpointURL), + attribute.String("cfg.token_endpoint_url", cfg.TokenEndpointURL), + attribute.StringSlice("cfg.scopes", cfg.Scopes), + attribute.Bool("cfg.use_pkce", cfg.UsePKCE), + attribute.String("cfg.client_id", clientID), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanConfigAttrs(cfg *CredentialsConfig, clientID string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.grant_type", "refresh_token"), + attribute.String("cfg.token_endpoint_url", cfg.TokenEndpointURL), + attribute.StringSlice("cfg.scopes", cfg.Scopes), + attribute.String("cfg.client_id", clientID), + attribute.String("correlation_id", correlationID), + } +} + +func resultAttrs(tok *oauth2.Token) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.Bool("credential.expires", !tok.Expiry.IsZero()), + } + if !tok.Expiry.IsZero() { + attrs = append(attrs, attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339))) + } + + return attrs +} diff --git a/pkg/oauth2cc/oauth2cc.go b/pkg/oauth2cc/oauth2cc.go index af94d47..e580b7e 100644 --- a/pkg/oauth2cc/oauth2cc.go +++ b/pkg/oauth2cc/oauth2cc.go @@ -12,6 +12,9 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" corev1 "k8s.io/api/core/v1" @@ -20,6 +23,7 @@ import ( "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) @@ -64,6 +68,8 @@ type credentialsConfig struct { authStyle oauth2.AuthStyle scopes []string additionalParams map[string][]string + + tracerProvider trace.TracerProvider } type credentialsProvider struct { @@ -207,7 +213,27 @@ func (r *tokenRetriever) start(ctx context.Context) error { return nil } +func (r *tokenRetriever) fetchToken(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, ccCfg clientcredentials.Config) (*oauth2.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + token, err := ccCfg.Token(fetchCtx) + if err != nil { + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs(token)...) + tokenextelemetry.RecordResult(span, nil) + + return token, nil +} + func (r *tokenRetriever) tokenRefresherLoop(ctx context.Context) { + tracer := tokenextelemetry.Tracer(ctx, r.cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + for { r.mu.Lock() clientID := r.clientID @@ -228,7 +254,9 @@ func (r *tokenRetriever) tokenRefresherLoop(ctx context.Context) { return } - cfg := clientcredentials.Config{ + configAttrs := fetchSpanConfigAttrs(&r.cfg, clientID, correlationID) + + ccCfg := clientcredentials.Config{ ClientID: clientID, ClientSecret: clientSecret, TokenURL: r.cfg.tokenEndpointURL, @@ -237,7 +265,7 @@ func (r *tokenRetriever) tokenRefresherLoop(ctx context.Context) { AuthStyle: r.cfg.authStyle, } - token, err := cfg.Token(ctx) + token, err := r.fetchToken(ctx, tracer, configAttrs, ccCfg) if err != nil { util.SendToChannel(r.ch, Credential{ Event: credential.UpdateEventType, diff --git a/pkg/oauth2cc/option.go b/pkg/oauth2cc/option.go index 09c3c34..6ebfa0b 100644 --- a/pkg/oauth2cc/option.go +++ b/pkg/oauth2cc/option.go @@ -4,6 +4,7 @@ package oauth2cc import ( + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "go.riptides.io/tokenex/pkg/option" @@ -79,3 +80,11 @@ func WithAuthStyle(authStyle oauth2.AuthStyle) option.Option { c.authStyle = authStyle }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/oauth2cc/telemetry.go b/pkg/oauth2cc/telemetry.go new file mode 100644 index 0000000..4167505 --- /dev/null +++ b/pkg/oauth2cc/telemetry.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package oauth2cc + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/oauth2cc" + +func authStyleString(s oauth2.AuthStyle) string { + switch s { + case oauth2.AuthStyleInParams: + return "in_params" + case oauth2.AuthStyleInHeader: + return "in_header" + default: + return "auto_detect" + } +} + +func fetchSpanConfigAttrs(cfg *credentialsConfig, clientID string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.token_endpoint_url", cfg.tokenEndpointURL), + attribute.StringSlice("cfg.scopes", cfg.scopes), + attribute.String("cfg.auth_style", authStyleString(cfg.authStyle)), + attribute.String("cfg.client_id", clientID), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanResultAttrs(tok *oauth2.Token) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339)), + } +} diff --git a/pkg/oci/creds.go b/pkg/oci/creds.go index 43e7f0b..8a93962 100644 --- a/pkg/oci/creds.go +++ b/pkg/oci/creds.go @@ -16,9 +16,13 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -30,6 +34,8 @@ type credentialsConfig struct { identityDomainURL string rsaPubKeyDer []byte identityTokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider } // CredentialsProvider defines the interface for obtaining OCI credentials. @@ -87,39 +93,63 @@ type Provider interface { func (cp *credentialsProvider) isOCI() {} +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, cfg *credentialsConfig, tokenEndpoint string, publicKey string) (credential.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + idToken, err := cfg.identityTokenProvider.GetToken(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "failed to get identity token") + tokenextelemetry.RecordResult(span, err) + + return credential.Token{}, err + } + + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + + authToken, err := exchangeToken(fetchCtx, tokenEndpoint, cfg.clientID, cfg.clientSecret, idToken.Token, publicKey) + if err != nil { + err = errors.WrapIf(err, "token exchange failed") + tokenextelemetry.RecordResult(span, err) + + return credential.Token{}, err + } + + expTime, err := getTokenExpiration(authToken) + if err != nil { + err = errors.WrapIf(err, "failed to get expiration time of the received UPST") + tokenextelemetry.RecordResult(span, err) + + return credential.Token{}, err + } + + ociCredential := credential.Token{ + Token: authToken.Raw, + ExpiresAt: expTime, + } + + span.SetAttributes(fetchSpanResultAttrs(expTime)...) + tokenextelemetry.RecordResult(span, nil) + + return ociCredential, nil +} + // refreshCredentialsLoop handles the credential retrieval and refresh loop. func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) tokenEndpoint := getTokenEndpoint(cfg.identityDomainURL) publicKey := base64.StdEncoding.EncodeToString(cfg.rsaPubKeyDer) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) for { - idToken, err := cfg.identityTokenProvider.GetToken(ctx) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to get identity token")) - - return - } - - authToken, err := exchangeToken(ctx, tokenEndpoint, cfg.clientID, cfg.clientSecret, idToken.Token, publicKey) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "token exchange failed")) - - return - } - - expTime, err := getTokenExpiration(authToken) + ociCredential, err := fetchCredentials(ctx, tracer, configAttrs, cfg, tokenEndpoint, publicKey) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to get expiration time of the received UPST")) + util.SendErrorToChannel(credsChan, err) return } - // Send credentials - ociCredential := credential.Token{ - Token: authToken.Raw, - ExpiresAt: expTime, - } - // Calculate when to refresh timeUntilExpiry := time.Until(ociCredential.ExpiresAt) diff --git a/pkg/oci/option.go b/pkg/oci/option.go index a92e8b6..b034bff 100644 --- a/pkg/oci/option.go +++ b/pkg/oci/option.go @@ -4,6 +4,8 @@ package oci import ( + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -75,3 +77,11 @@ func WithIdentityTokenProvider(tokenProvider token.IdentityTokenProvider) option c.identityTokenProvider = tokenProvider }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/oci/telemetry.go b/pkg/oci/telemetry.go new file mode 100644 index 0000000..7d9181a --- /dev/null +++ b/pkg/oci/telemetry.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package oci + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/oci" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("cfg.client_id", cfg.clientID), + attribute.String("cfg.identity_domain_url", cfg.identityDomainURL), + attribute.String("correlation_id", correlationID), + } + + return attrs +} + +func fetchSpanResultAttrs(expiresAt time.Time) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", expiresAt.UTC().Format(time.RFC3339)), + } +} diff --git a/pkg/rfc7523/option.go b/pkg/rfc7523/option.go index 241558b..b2c47b2 100644 --- a/pkg/rfc7523/option.go +++ b/pkg/rfc7523/option.go @@ -6,6 +6,8 @@ package rfc7523 import ( "net/http" + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -89,3 +91,11 @@ func WithHTTPClient(client *http.Client) option.Option { c.httpClient = client }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/rfc7523/rfc7523.go b/pkg/rfc7523/rfc7523.go index 5ca7bb3..e4dbec0 100644 --- a/pkg/rfc7523/rfc7523.go +++ b/pkg/rfc7523/rfc7523.go @@ -26,10 +26,14 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -80,6 +84,8 @@ type credentialsConfig struct { additionalFields map[string]string bodyFormat BodyFormat httpClient *http.Client + + tracerProvider trace.TracerProvider } type credentialsProvider struct { @@ -154,6 +160,34 @@ func validateConfig(cfg *credentialsConfig) error { return nil } +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, httpClient *http.Client, cfg *credentialsConfig) (*oauth2.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + identityToken, err := cfg.tokenProvider.GetToken(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "could not get identity token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", identityToken.Token, identityToken.ExpiresAt)...) + + tok, err := exchangeToken(fetchCtx, httpClient, cfg, identityToken.Token) + if err != nil { + err = errors.WrapIf(err, "could not exchange jwt-bearer token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs(tok)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil +} + func (cp *credentialsProvider) refreshCredentialsLoop( ctx context.Context, cfg *credentialsConfig, @@ -164,6 +198,10 @@ func (cp *credentialsProvider) refreshCredentialsLoop( httpClient = http.DefaultClient } + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) + for { select { case <-ctx.Done(): @@ -171,16 +209,9 @@ func (cp *credentialsProvider) refreshCredentialsLoop( default: } - identityToken, err := cfg.tokenProvider.GetToken(ctx) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not get identity token")) - - return - } - - tok, err := exchangeToken(ctx, httpClient, cfg, identityToken.Token) + tok, err := fetchCredentials(ctx, tracer, configAttrs, httpClient, cfg) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not exchange jwt-bearer token")) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/rfc7523/telemetry.go b/pkg/rfc7523/telemetry.go new file mode 100644 index 0000000..955136b --- /dev/null +++ b/pkg/rfc7523/telemetry.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package rfc7523 + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/rfc7523" + +// allowedAdditionalFieldKeys defines the cfg.additionalFields keys that may be +// exposed in telemetry. These fields are populated by the Anthropic WIF +// integration and are limited to non-secret identifiers. +var allowedAdditionalFieldKeys = []string{ + "federation_rule_id", + "organization_id", + "service_account_id", + "workspace_id", +} + +func bodyFormatString(f BodyFormat) string { + switch f { + case BodyFormatJSON: + return "json" + default: + return "form" + } +} + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 4+len(allowedAdditionalFieldKeys)) + attrs = append(attrs, + attribute.String("cfg.token_endpoint_url", cfg.tokenEndpointURL), + attribute.StringSlice("cfg.scopes", cfg.scopes), + attribute.String("cfg.body_format", bodyFormatString(cfg.bodyFormat)), + attribute.String("correlation_id", correlationID), + ) + + for _, k := range allowedAdditionalFieldKeys { + if v, ok := cfg.additionalFields[k]; ok { + attrs = append(attrs, attribute.String("cfg."+k, v)) + } + } + + return attrs +} + +func fetchSpanResultAttrs(tok *oauth2.Token) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.Bool("credential.expires", !tok.Expiry.IsZero()), + } + if !tok.Expiry.IsZero() { + attrs = append(attrs, attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339))) + } + + return attrs +} diff --git a/pkg/rfc8693/option.go b/pkg/rfc8693/option.go index c411cdb..fcbd26c 100644 --- a/pkg/rfc8693/option.go +++ b/pkg/rfc8693/option.go @@ -6,6 +6,8 @@ package rfc8693 import ( "net/http" + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -98,3 +100,11 @@ func WithHTTPClient(client *http.Client) option.Option { c.httpClient = client }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/rfc8693/rfc8693.go b/pkg/rfc8693/rfc8693.go index 01e7132..e492d5c 100644 --- a/pkg/rfc8693/rfc8693.go +++ b/pkg/rfc8693/rfc8693.go @@ -27,10 +27,14 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -75,6 +79,8 @@ type credentialsConfig struct { scopes []string additionalFields map[string]string httpClient *http.Client + + tracerProvider trace.TracerProvider } type credentialsProvider struct { @@ -148,6 +154,49 @@ func validateConfig(cfg *credentialsConfig) error { return nil } +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, httpClient *http.Client, cfg *credentialsConfig) (*oauth2.Token, error) { + fetchCtx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + identityToken, err := cfg.subjectTokenProvider.GetToken(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "could not get subject token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", identityToken.Token, identityToken.ExpiresAt)...) + + var actorToken string + + if cfg.actorTokenProvider != nil { + at, err := cfg.actorTokenProvider.GetToken(fetchCtx) + if err != nil { + err = errors.WrapIf(err, "could not get actor token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + actorToken = at.Token + span.SetAttributes(tokenextelemetry.IdentityTokenAttrs("actor_token", at.Token, at.ExpiresAt)...) + } + + tok, err := exchangeToken(fetchCtx, httpClient, cfg, identityToken.Token, actorToken) + if err != nil { + err = errors.WrapIf(err, "could not exchange token") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs(tok)...) + tokenextelemetry.RecordResult(span, nil) + + return tok, nil +} + func (cp *credentialsProvider) refreshCredentialsLoop( ctx context.Context, cfg *credentialsConfig, @@ -158,6 +207,10 @@ func (cp *credentialsProvider) refreshCredentialsLoop( httpClient = http.DefaultClient } + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, correlationID) + for { select { case <-ctx.Done(): @@ -165,29 +218,9 @@ func (cp *credentialsProvider) refreshCredentialsLoop( default: } - identityToken, err := cfg.subjectTokenProvider.GetToken(ctx) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not get subject token")) - - return - } - - var actorToken string - - if cfg.actorTokenProvider != nil { - at, err := cfg.actorTokenProvider.GetToken(ctx) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not get actor token")) - - return - } - - actorToken = at.Token - } - - tok, err := exchangeToken(ctx, httpClient, cfg, identityToken.Token, actorToken) + tok, err := fetchCredentials(ctx, tracer, configAttrs, httpClient, cfg) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "could not exchange token")) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/rfc8693/telemetry.go b/pkg/rfc8693/telemetry.go new file mode 100644 index 0000000..7eaf86e --- /dev/null +++ b/pkg/rfc8693/telemetry.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package rfc8693 + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/rfc8693" + +// allowedAdditionalFieldKeys defines the cfg.additionalFields keys that may be +// exposed in telemetry. These fields are populated by the OpenAI WIF integration +// and are restricted to non-secret identifiers. +var allowedAdditionalFieldKeys = []string{ + "identity_provider_id", + "service_account_id", + "client_id", +} + +func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 7+len(allowedAdditionalFieldKeys)) + attrs = append(attrs, + attribute.String("cfg.token_endpoint_url", cfg.tokenEndpointURL), + attribute.StringSlice("cfg.scopes", cfg.scopes), + attribute.String("cfg.subject_token_type", string(cfg.subjectTokenType)), + attribute.String("correlation_id", correlationID), + ) + + if cfg.actorTokenProvider != nil { + actorTokenType := cfg.actorTokenType + if actorTokenType == "" { + actorTokenType = TokenTypeJWT + } + + attrs = append(attrs, attribute.String("cfg.actor_token_type", string(actorTokenType))) + } + + if cfg.requestedTokenType != "" { + attrs = append(attrs, attribute.String("cfg.requested_token_type", cfg.requestedTokenType)) + } + + if cfg.audience != "" { + attrs = append(attrs, attribute.String("cfg.audience", cfg.audience)) + } + + for _, k := range allowedAdditionalFieldKeys { + if v, ok := cfg.additionalFields[k]; ok { + attrs = append(attrs, attribute.String("cfg."+k, v)) + } + } + + return attrs +} + +func fetchSpanResultAttrs(tok *oauth2.Token) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.Bool("credential.expires", !tok.Expiry.IsZero()), + } + if !tok.Expiry.IsZero() { + attrs = append(attrs, attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339))) + } + + return attrs +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 0000000..cb28c8e --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +// Package telemetry lets tokenex credential providers receive an OTel +// TracerProvider from their consumer, and derive their own +// appropriately-scoped Tracer from it. +package telemetry + +import ( + "context" + "time" + + "github.com/golang-jwt/jwt/v5" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Tracer returns a Tracer scoped to scopeName from tp. If tp is nil, it +// falls back to the TracerProvider of the current span in ctx (if any), and +// finally to the OTel global TracerProvider. Providers should call this with +// their own package's instrumentation scope name rather than accepting a +// Tracer directly, so the scope always reflects the actual source of the +// spans regardless of what the consumer supplies. +func Tracer(ctx context.Context, tp trace.TracerProvider, scopeName string) trace.Tracer { + if tp == nil { + if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() { + tp = span.TracerProvider() + } else { + tp = otel.GetTracerProvider() + } + } + + return tp.Tracer(scopeName) +} + +// IdentityTokenAttrs returns span attributes describing an identity token +// presented to a downstream exchange: .expires_at always, plus +// .subject / .audience / .iss when rawToken parses as +// a JWT and carries those claims (rawToken is parsed unverified — this is for +// telemetry, not validation). +func IdentityTokenAttrs(prefix string, rawToken string, expiresAt time.Time) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String(prefix+".expires_at", expiresAt.UTC().Format(time.RFC3339)), + } + + tok, _, err := jwt.NewParser().ParseUnverified(rawToken, jwt.MapClaims{}) + if err != nil { + return attrs + } + + if sub, err := tok.Claims.GetSubject(); err == nil && sub != "" { + attrs = append(attrs, attribute.String(prefix+".subject", sub)) + } + if aud, err := tok.Claims.GetAudience(); err == nil && len(aud) > 0 { + attrs = append(attrs, attribute.StringSlice(prefix+".audience", aud)) + } + if iss, err := tok.Claims.GetIssuer(); err == nil && iss != "" { + attrs = append(attrs, attribute.String(prefix+".iss", iss)) + } + + return attrs +} + +// RecordResult records err on span (if non-nil) and sets the span's final +// status accordingly. It does not end the span — callers should +// `defer span.End()` right after starting it, so the span is always closed +// regardless of which return path is taken. +func RecordResult(span trace.Span, err error) { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } else { + span.SetStatus(codes.Ok, "") + } +} diff --git a/pkg/util/context_holder.go b/pkg/util/context_holder.go new file mode 100644 index 0000000..7d81030 --- /dev/null +++ b/pkg/util/context_holder.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package util + +import ( + "context" + "sync/atomic" +) + +// ContextHolder atomically stores a context.Context that can be set from one +// goroutine and read from another, for smuggling a context through +// third-party interfaces that don't accept one as a parameter. +type ContextHolder struct { + ptr atomic.Pointer[context.Context] +} + +// Store sets the context returned by subsequent calls to Context. +func (h *ContextHolder) Store(ctx context.Context) { + h.ptr.Store(&ctx) +} + +// Context returns the context last passed to Store, or context.Background() +// if Store has never been called. +func (h *ContextHolder) Context() context.Context { + if p := h.ptr.Load(); p != nil { + return *p + } + + return context.Background() +} diff --git a/pkg/vault/azure.go b/pkg/vault/azure.go index 96cd4d7..d412aac 100644 --- a/pkg/vault/azure.go +++ b/pkg/vault/azure.go @@ -13,11 +13,33 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/cenkalti/backoff/v5" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) +const azureExchangeSpanName = "vault.azure.exchange_access_token" + +func azureExchangeSpanConfigAttrs(tenantID string, clientID string, scopes []string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.tenant_id", tenantID), + attribute.String("cfg.client_id", clientID), + attribute.StringSlice("cfg.scopes", scopes), + attribute.String("correlation_id", correlationID), + } +} + +func azureExchangeSpanResultAttrs(tok azcore.AccessToken) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.ExpiresOn.UTC().Format(time.RFC3339)), + } +} + // VaultAzureSecret represents the structure of the secret data returned by Vault's Azure secrets engine when configured to return Azure credentials. type vaultAzureSecret struct { ClientID string `mapstructure:"client_id"` @@ -29,31 +51,53 @@ type azureAccessTokenProvider struct { clientID string clientSecret string scopes []string + tracer trace.Tracer logger logr.Logger } -// GetCredentials begins the process of exchanging the client ID and client secret for an Azure access token and refreshing it as needed until the context is canceled. -func (r *azureAccessTokenProvider) GetCredentials(ctx context.Context, credsChan chan credential.Result) { +func (r *azureAccessTokenProvider) fetchAccessToken(ctx context.Context, parentSpanContext trace.SpanContext, configAttrs []attribute.KeyValue) (azcore.AccessToken, error) { + fetchCtx, span := r.tracer.Start(ctx, azureExchangeSpanName, trace.WithAttributes(configAttrs...), trace.WithLinks(trace.Link{SpanContext: parentSpanContext})) + defer span.End() + + azClientCreds, err := azidentity.NewClientSecretCredential(r.tenantID, r.clientID, r.clientSecret, nil) + if err != nil { + err = errors.WrapIf(err, "failed to create Azure client secret credential") + tokenextelemetry.RecordResult(span, err) + + return azcore.AccessToken{}, err + } + b := backoff.NewExponentialBackOff() + token, err := backoff.Retry(fetchCtx, func() (azcore.AccessToken, error) { + token, err := azClientCreds.GetToken(fetchCtx, policy.TokenRequestOptions{ + Scopes: r.scopes, + }) - for { - azClientCreds, err := azidentity.NewClientSecretCredential(r.tenantID, r.clientID, r.clientSecret, nil) - if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to create Azure client secret credential")) + return token, err + }, backoff.WithBackOff(b), backoff.WithMaxElapsedTime(30*time.Second)) + if err != nil { + err = errors.WrapIf(err, "failed to get Azure access token from client secret credential") + tokenextelemetry.RecordResult(span, err) - return - } + return azcore.AccessToken{}, err + } - token, err := backoff.Retry(ctx, func() (azcore.AccessToken, error) { - token, err := azClientCreds.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: r.scopes, - }) + span.SetAttributes(azureExchangeSpanResultAttrs(token)...) + tokenextelemetry.RecordResult(span, nil) - return token, err - }, backoff.WithBackOff(b), backoff.WithMaxElapsedTime(30*time.Second)) + return token, nil +} + +// GetCredentials begins the process of exchanging the client ID and client secret for an Azure access token and refreshing it as needed until the context is canceled. +func (r *azureAccessTokenProvider) GetCredentials(ctx context.Context, parentSpanContext trace.SpanContext, credsChan chan credential.Result) { + correlationID := uuid.NewString() + configAttrs := azureExchangeSpanConfigAttrs(r.tenantID, r.clientID, r.scopes, correlationID) + + for { + token, err := r.fetchAccessToken(ctx, parentSpanContext, configAttrs) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to get Azure access token from client secret credential")) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/vault/creds.go b/pkg/vault/creds.go index ee7cab6..623ccc5 100644 --- a/pkg/vault/creds.go +++ b/pkg/vault/creds.go @@ -10,11 +10,15 @@ import ( "emperror.dev/errors" "github.com/go-logr/logr" "github.com/go-viper/mapstructure/v2" + "github.com/google/uuid" jwtauth "github.com/openbao/openbao/api/auth/jwt/v2" "github.com/openbao/openbao/api/v2" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/token" "go.riptides.io/tokenex/pkg/util" ) @@ -97,6 +101,8 @@ type credentialsConfig struct { reqData map[string][]string identityTokenProvider token.IdentityTokenProvider + tracerProvider trace.TracerProvider + gcp *gcpCredentialConfig azure *azureCredentialConfig } @@ -216,6 +222,8 @@ func (cp *credentialsProvider) authenticate(ctx context.Context, cfg *credential return errors.WrapIf(err, "failed to get ID token") } + trace.SpanFromContext(ctx).SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + // Authenticate with Vault using JWT err = cp.authenticateWithJWT(ctx, idToken, cfg.jwtAuthMethodPath, cfg.jwtAuthRoleName) if err != nil { @@ -281,8 +289,27 @@ func (cp *credentialsProvider) retrieveCredentials(ctx context.Context, cfg *cre }, nil } +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, cp *credentialsProvider, cfg *credentialsConfig) (*credentialData, trace.SpanContext, error) { + ctx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + sc := span.SpanContext() + + creds, err := cp.retrieveCredentials(ctx, cfg) + if err != nil { + err = errors.WrapIfWithDetails(err, "failed to retrieve secret", "secret_path", cfg.secretFullPath) + tokenextelemetry.RecordResult(span, err) + + return nil, sc, err + } + + span.SetAttributes(fetchSpanResultAttrs(creds)...) + tokenextelemetry.RecordResult(span, nil) + + return creds, sc, nil +} + // startGcpAccessTokenProvider starts a worker goroutine that exchanges a GCP service account key for an access token and refreshes it as needed until the context is canceled. -func (cp *credentialsProvider) startGcpAccessTokenProvider(ctx context.Context, secretData map[string]any, scopes []string) (<-chan credential.Result, error) { +func (cp *credentialsProvider) startGcpAccessTokenProvider(ctx context.Context, tracer trace.Tracer, parentSpanContext trace.SpanContext, secretData map[string]any, scopes []string) (<-chan credential.Result, error) { var serviceAccountKeySecret gcpServiceAccountKeySecret if err := mapstructure.Decode(secretData, &serviceAccountKeySecret); err != nil { return nil, errors.WrapIf(err, "failed to decode Vault secret data into GCP service account key credentials structure") @@ -300,20 +327,21 @@ func (cp *credentialsProvider) startGcpAccessTokenProvider(ctx context.Context, provider := gcpAccessTokenProvider{ serviceAccountKeyJSON: keyJSON, scopes: scopes, + tracer: tracer, logger: logr.FromContextOrDiscard(ctx).WithName("gcp_access_token_provider"), } credsChan := make(chan credential.Result, 1) go func() { defer close(credsChan) - provider.GetCredentials(ctx, credsChan) + provider.GetCredentials(ctx, parentSpanContext, credsChan) }() return credsChan, nil } // startAzureAccessTokenProvider starts a worker goroutine that exchanges Azure credentials for an access token and refreshes it as needed until the context is canceled. -func (cp *credentialsProvider) startAzureAccessTokenProvider(ctx context.Context, secretData map[string]any, tenantID string, scopes []string) (<-chan credential.Result, error) { +func (cp *credentialsProvider) startAzureAccessTokenProvider(ctx context.Context, tracer trace.Tracer, parentSpanContext trace.SpanContext, secretData map[string]any, tenantID string, scopes []string) (<-chan credential.Result, error) { var azureSecret vaultAzureSecret if err := mapstructure.Decode(secretData, &azureSecret); err != nil { @@ -329,13 +357,14 @@ func (cp *credentialsProvider) startAzureAccessTokenProvider(ctx context.Context clientID: azureSecret.ClientID, clientSecret: azureSecret.ClientSecret, scopes: scopes, + tracer: tracer, logger: logr.FromContextOrDiscard(ctx).WithName("azure_access_token_provider"), } credsChan := make(chan credential.Result, 1) go func() { defer close(credsChan) - provider.GetCredentials(ctx, credsChan) + provider.GetCredentials(ctx, parentSpanContext, credsChan) }() return credsChan, nil @@ -346,12 +375,12 @@ func shouldStartWorker(cfg *credentialsConfig) bool { return cfg.gcp.ExchangeSAKeyForAccessToken() || cfg.azure.ExchangeForAccessToken() } -func (cp *credentialsProvider) startWorker(ctx context.Context, cfg *credentialsConfig, credsData map[string]any) (<-chan credential.Result, context.CancelFunc, error) { +func (cp *credentialsProvider) startWorker(ctx context.Context, tracer trace.Tracer, parentSpanContext trace.SpanContext, cfg *credentialsConfig, credsData map[string]any) (<-chan credential.Result, context.CancelFunc, error) { if cfg.gcp.ExchangeSAKeyForAccessToken() { // get access token using the service account key data in the Vault secret // and send the access token through the channel instead of the raw service account key data ctx, cancel := context.WithCancel(ctx) - credsChan, err := cp.startGcpAccessTokenProvider(ctx, credsData, cfg.gcp.accessTokenScopes) + credsChan, err := cp.startGcpAccessTokenProvider(ctx, tracer, parentSpanContext, credsData, cfg.gcp.accessTokenScopes) if err != nil { return nil, cancel, errors.WrapIf(err, "failed to start GCP access token provider") } @@ -362,7 +391,7 @@ func (cp *credentialsProvider) startWorker(ctx context.Context, cfg *credentials if cfg.azure.ExchangeForAccessToken() { // get access token using the client ID and client secret data in the Vault secret ctx, cancel := context.WithCancel(ctx) - credsChan, err := cp.startAzureAccessTokenProvider(ctx, credsData, cfg.azure.TenantID(), cfg.azure.AccessTokenScopes()) + credsChan, err := cp.startAzureAccessTokenProvider(ctx, tracer, parentSpanContext, credsData, cfg.azure.TenantID(), cfg.azure.AccessTokenScopes()) if err != nil { return nil, cancel, errors.WrapIf(err, "failed to start Azure access token provider") } @@ -375,6 +404,10 @@ func (cp *credentialsProvider) startWorker(ctx context.Context, cfg *credentials // refreshCredentialsLoop handles the credential retrieval and refresh loop. func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + correlationID := uuid.NewString() + configAttrs := fetchSpanConfigAttrs(cfg, cp.client.Address(), correlationID) + var cancelWorker context.CancelFunc var workerChan <-chan credential.Result @@ -396,9 +429,9 @@ func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg * } // Retrieve the secret - creds, err := cp.retrieveCredentials(ctx, cfg) + creds, sc, err := fetchCredentials(ctx, tracer, configAttrs, cp, cfg) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIfWithDetails(err, "failed to retrieve secret", "secret_path", cfg.secretFullPath)) + util.SendErrorToChannel(credsChan, err) return } @@ -414,7 +447,7 @@ func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg * } if shouldStartWorker(cfg) { - workerChan, cancelWorker, err = cp.startWorker(logr.NewContext(ctx, logger), cfg, creds.Data) + workerChan, cancelWorker, err = cp.startWorker(logr.NewContext(ctx, logger), tracer, sc, cfg, creds.Data) if err != nil { util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to start credential worker")) diff --git a/pkg/vault/gcp.go b/pkg/vault/gcp.go index a332fd0..9cf77e9 100644 --- a/pkg/vault/gcp.go +++ b/pkg/vault/gcp.go @@ -12,13 +12,33 @@ import ( "emperror.dev/errors" "github.com/cenkalti/backoff/v5" "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "go.riptides.io/tokenex/pkg/credential" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" "go.riptides.io/tokenex/pkg/util" ) +const gcpExchangeSpanName = "vault.gcp.exchange_access_token" + +func gcpExchangeSpanConfigAttrs(scopes []string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.StringSlice("cfg.scopes", scopes), + attribute.String("correlation_id", correlationID), + } +} + +func gcpExchangeSpanResultAttrs(tok *oauth2.Token) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", tok.Expiry.UTC().Format(time.RFC3339)), + } +} + // gcpServiceAccountKeySecret represents the structure of the service account key material returned by Vault's Google Cloud secrets engine when configured to return service account keys. // It contains the base64-encoded private key data. type gcpServiceAccountKeySecret struct { @@ -39,41 +59,63 @@ func (s *gcpServiceAccountKeySecret) ServiceAccountKeyJSON() ([]byte, error) { type gcpAccessTokenProvider struct { serviceAccountKeyJSON []byte scopes []string + tracer trace.Tracer logger logr.Logger } -// GetCredentials begins the process of exchanging the service account key for an access token and refreshing it as needed until the context is canceled. -func (r *gcpAccessTokenProvider) GetCredentials(ctx context.Context, credsChan chan credential.Result) { - b := backoff.NewExponentialBackOff() +func (r *gcpAccessTokenProvider) fetchAccessToken(ctx context.Context, parentSpanContext trace.SpanContext, configAttrs []attribute.KeyValue) (*oauth2.Token, error) { + fetchCtx, span := r.tracer.Start(ctx, gcpExchangeSpanName, trace.WithAttributes(configAttrs...), trace.WithLinks(trace.Link{SpanContext: parentSpanContext})) + defer span.End() - for { - // use the service account key to authenticate to GCP and obtain an access token - gcpCreds, err := google.CredentialsFromJSON(ctx, r.serviceAccountKeyJSON, r.scopes...) + // use the service account key to authenticate to GCP and obtain an access token + gcpCreds, err := google.CredentialsFromJSON(fetchCtx, r.serviceAccountKeyJSON, r.scopes...) + if err != nil { + err = errors.WrapIf(err, "failed to obtain GCP credentials from service account key") + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + // if the SA key was just created it's possible that it may take a few seconds for GCP to propagate the key and allow it to be used for authentication. + // This can result in transient errors when trying to exchange the key for an access token. + b := backoff.NewExponentialBackOff() + token, err := backoff.Retry(fetchCtx, func() (*oauth2.Token, error) { + token, err := gcpCreds.TokenSource.Token() if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to obtain GCP credentials from service account key")) + if strings.Contains(err.Error(), "invalid_grant") { + r.logger.V(2).Info("Received invalid_grant error when exchanging service account key for access token, likely due to GCP propagation delay. Retrying...", "error", err) - return + return nil, err + } + + return nil, backoff.Permanent(errors.WrapIf(err, "failed to exchange service account key for access token")) } - // if the SA key was just created it's possible that it may take a few seconds for GCP to propagate the key and allow it to be used for authentication. - // This can result in transient errors when trying to exchange the key for an access token. - token, err := backoff.Retry(ctx, func() (*oauth2.Token, error) { - token, err := gcpCreds.TokenSource.Token() - if err != nil { - if strings.Contains(err.Error(), "invalid_grant") { - r.logger.V(2).Info("Received invalid_grant error when exchanging service account key for access token, likely due to GCP propagation delay. Retrying...", "error", err) + return token, nil + }, backoff.WithBackOff(b), backoff.WithMaxElapsedTime(30*time.Second)) + if err != nil { + err = errors.WrapIf(err, "failed to obtain access token from GCP using service account key") + tokenextelemetry.RecordResult(span, err) - return nil, err - } + return nil, err + } - return nil, backoff.Permanent(errors.WrapIf(err, "failed to exchange service account key for access token")) - } + span.SetAttributes(gcpExchangeSpanResultAttrs(token)...) + tokenextelemetry.RecordResult(span, nil) + + return token, nil +} - return token, nil - }, backoff.WithBackOff(b), backoff.WithMaxElapsedTime(30*time.Second)) +// GetCredentials begins the process of exchanging the service account key for an access token and refreshing it as needed until the context is canceled. +func (r *gcpAccessTokenProvider) GetCredentials(ctx context.Context, parentSpanContext trace.SpanContext, credsChan chan credential.Result) { + correlationID := uuid.NewString() + configAttrs := gcpExchangeSpanConfigAttrs(r.scopes, correlationID) + + for { + token, err := r.fetchAccessToken(ctx, parentSpanContext, configAttrs) if err != nil { - util.SendErrorToChannel(credsChan, errors.WrapIf(err, "failed to obtain access token from GCP using service account key")) + util.SendErrorToChannel(credsChan, err) return } diff --git a/pkg/vault/option.go b/pkg/vault/option.go index c86ea61..5f6321b 100644 --- a/pkg/vault/option.go +++ b/pkg/vault/option.go @@ -6,6 +6,8 @@ package vault import ( "time" + "go.opentelemetry.io/otel/trace" + "go.riptides.io/tokenex/pkg/option" "go.riptides.io/tokenex/pkg/token" ) @@ -157,3 +159,11 @@ func WithAzureTenantID(tenantID string) option.Option { c.azure.tenantID = tenantID }) } + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/vault/telemetry.go b/pkg/vault/telemetry.go new file mode 100644 index 0000000..518bc85 --- /dev/null +++ b/pkg/vault/telemetry.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package vault + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/vault" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, vaultAddr string, correlationID string) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("cfg.vault_addr", vaultAddr), + attribute.String("cfg.jwt_auth_method_path", cfg.jwtAuthMethodPath), + attribute.String("cfg.jwt_auth_role_name", cfg.jwtAuthRoleName), + attribute.String("cfg.secret_full_path", cfg.secretFullPath), + attribute.String("correlation_id", correlationID), + } +} + +func fetchSpanResultAttrs(creds *credentialData) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", true), + attribute.String("credential.expires_at", creds.ExpiresAt.UTC().Format(time.RFC3339)), + } +}