From 91056efb0aab930cb193cfc53be28d2c38a036af Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 11:06:23 -0500 Subject: [PATCH 1/5] Ensure custom user-agent flows to auth HTTP calls (#2723) Add the azd user-agent string (azdev/) to all authentication HTTP calls made through Azure Identity SDK credentials and MSAL. Previously, auth calls used the SDK default user-agent, making it impossible to identify azd-originated auth traffic in telemetry. Changes: - Add userAgent field to auth.Manager, passed from container registration - Add authClientOptions() helper that sets Telemetry.ApplicationID on all azidentity credential ClientOptions (ManagedIdentity, ClientSecret, ClientCertificate, FederatedToken, AzurePipelines) - Add userAgentClient wrapper to inject user-agent on MSAL HTTP calls - Remove stale TODO comments about user-agent injection Fixes #2723 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 13 ++++- cli/azd/pkg/auth/manager.go | 47 +++++++++++-------- cli/azd/pkg/auth/user_agent_client.go | 32 +++++++++++++ .../pkg/devcentersdk/developer_client_test.go | 1 + cli/azd/test/functional/remote_state_test.go | 1 + 5 files changed, 73 insertions(+), 21 deletions(-) create mode 100644 cli/azd/pkg/auth/user_agent_client.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index c5a373068a9..69faf0d6daa 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -634,7 +634,18 @@ func registerCommonDependencies(container *ioc.NestedContainer) { Key: key, }, nil }) - container.MustRegisterScoped(auth.NewManager) + container.MustRegisterScoped(func( + configManager config.FileConfigManager, + userConfigManager config.UserConfigManager, + cloud *cloud.Cloud, + httpClient auth.HttpClient, + console input.Console, + externalAuthCfg auth.ExternalAuthConfiguration, + azCli az.AzCli, + ) (*auth.Manager, error) { + return auth.NewManager( + configManager, userConfigManager, cloud, httpClient, console, externalAuthCfg, azCli, internal.UserAgent()) + }) container.MustRegisterSingleton(azapi.NewUserProfileService) container.MustRegisterScoped(func(authManager *auth.Manager) middleware.CurrentUserAuthManager { return authManager diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index 194d2313bfe..c33879d05bd 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -98,6 +98,7 @@ type Manager struct { console input.Console externalAuthCfg ExternalAuthConfiguration azCli az.AzCli + userAgent string } type ExternalAuthConfiguration struct { @@ -114,6 +115,7 @@ func NewManager( console input.Console, externalAuthCfg ExternalAuthConfiguration, azCli az.AzCli, + userAgent string, ) (*Manager, error) { cfgRoot, err := config.GetUserConfigDir() if err != nil { @@ -135,10 +137,12 @@ func NewManager( return nil, fmt.Errorf("joining authority url: %w", err) } + msalClient := newUserAgentClient(httpClient, userAgent) + options := []public.Option{ public.WithCache(newCache(cacheRoot)), public.WithAuthority(authorityUrl), - public.WithHTTPClient(httpClient), + public.WithHTTPClient(msalClient), } publicClientApp, err := public.New(azdClientID, options...) @@ -157,9 +161,25 @@ func NewManager( console: console, externalAuthCfg: externalAuthCfg, azCli: azCli, + userAgent: userAgent, }, nil } +// authClientOptions returns azcore.ClientOptions configured with the custom user-agent policy +// for use with Azure Identity SDK credentials. +func (m *Manager) authClientOptions() azcore.ClientOptions { + opts := azcore.ClientOptions{ + Transport: m.httpClient, + Cloud: m.cloud.Configuration, + } + if m.userAgent != "" { + opts.Telemetry = policy.TelemetryOptions{ + ApplicationID: m.userAgent, + } + } + return opts +} + // LoginScopes returns the default scopes requested when logging in. func LoginScopes(cloud *cloud.Cloud) []string { arm := cloud.Configuration.Services[azcloud.ResourceManager] @@ -464,7 +484,9 @@ func (m *Manager) GetLoggedInServicePrincipalTenantID(ctx context.Context) (*str } func (m *Manager) newCredentialFromManagedIdentity(clientID string) (azcore.TokenCredential, error) { - options := &azidentity.ManagedIdentityCredentialOptions{} + options := &azidentity.ManagedIdentityCredentialOptions{ + ClientOptions: m.authClientOptions(), + } if clientID != "" { options.ID = azidentity.ClientID(clientID) } @@ -483,12 +505,7 @@ func (m *Manager) newCredentialFromClientSecret( clientSecret string, ) (azcore.TokenCredential, error) { options := &azidentity.ClientSecretCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: m.httpClient, - // TODO: Inject client options instead? this can be done if we're OK - // using the default user agent string. - Cloud: m.cloud.Configuration, - }, + ClientOptions: m.authClientOptions(), } cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, options) if err != nil { @@ -514,12 +531,7 @@ func (m *Manager) newCredentialFromClientCertificate( } options := &azidentity.ClientCertificateCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: m.httpClient, - // TODO: Inject client options instead? this can be done if we're OK - // using the default user agent string. - Cloud: m.cloud.Configuration, - }, + ClientOptions: m.authClientOptions(), } cred, err := azidentity.NewClientCertificateCredential( tenantID, clientID, certs, key, options) @@ -537,12 +549,7 @@ func (m *Manager) newCredentialFromFederatedTokenProvider( provider federatedTokenProvider, serviceConnectionID *string, ) (azcore.TokenCredential, error) { - clientOptions := azcore.ClientOptions{ - Transport: m.httpClient, - // TODO: Inject client options instead? this can be done if we're OK - // using the default user agent string. - Cloud: m.cloud.Configuration, - } + clientOptions := m.authClientOptions() switch provider { case gitHubFederatedTokenProvider: diff --git a/cli/azd/pkg/auth/user_agent_client.go b/cli/azd/pkg/auth/user_agent_client.go new file mode 100644 index 00000000000..c8cc99363b3 --- /dev/null +++ b/cli/azd/pkg/auth/user_agent_client.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package auth + +import "net/http" + +// userAgentClient wraps an HttpClient to inject a User-Agent header on all requests. +type userAgentClient struct { + inner HttpClient + userAgent string +} + +func newUserAgentClient(inner HttpClient, userAgent string) HttpClient { + if userAgent == "" { + return inner + } + return &userAgentClient{inner: inner, userAgent: userAgent} +} + +func (c *userAgentClient) Do(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", c.userAgent) + } else { + req.Header.Set("User-Agent", req.Header.Get("User-Agent")+","+c.userAgent) + } + return c.inner.Do(req) +} + +func (c *userAgentClient) CloseIdleConnections() { + c.inner.CloseIdleConnections() +} diff --git a/cli/azd/pkg/devcentersdk/developer_client_test.go b/cli/azd/pkg/devcentersdk/developer_client_test.go index 92faa224187..56139fffa26 100644 --- a/cli/azd/pkg/devcentersdk/developer_client_test.go +++ b/cli/azd/pkg/devcentersdk/developer_client_test.go @@ -36,6 +36,7 @@ func Test_DevCenter_Client(t *testing.T) { mockContext.Console, auth.ExternalAuthConfiguration{}, azCli, + "", ) require.NoError(t, err) diff --git a/cli/azd/test/functional/remote_state_test.go b/cli/azd/test/functional/remote_state_test.go index 3aba4ee5cac..091edb5cb94 100644 --- a/cli/azd/test/functional/remote_state_test.go +++ b/cli/azd/test/functional/remote_state_test.go @@ -89,6 +89,7 @@ func createBlobClient( httpClient, mockContext.Console, auth.ExternalAuthConfiguration{}, azCli, + "", ) require.NoError(t, err) From 1eb5b2e7d0d8a299818f886d480bc6785f50a7ca Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 11:10:26 -0500 Subject: [PATCH 2/5] Address review: cover Login* credential paths, guard nil headers - Apply authClientOptions() to LoginWithManagedIdentity, LoginWithServicePrincipalSecret, LoginWithServicePrincipalCertificate, and LoginWithAzurePipelinesFederatedTokenProvider - Remove remaining stale TODO comments - Guard against nil req.Header in userAgentClient.Do() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/auth/manager.go | 19 ++++++++++--------- cli/azd/pkg/auth/user_agent_client.go | 3 +++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index c33879d05bd..5bcc87e41f2 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -852,7 +852,9 @@ func (m *Manager) LoginWithDeviceCode( } func (m *Manager) LoginWithManagedIdentity(ctx context.Context, clientID string) (azcore.TokenCredential, error) { - options := &azidentity.ManagedIdentityCredentialOptions{} + options := &azidentity.ManagedIdentityCredentialOptions{ + ClientOptions: m.authClientOptions(), + } if clientID != "" { options.ID = azidentity.ClientID(clientID) } @@ -872,7 +874,9 @@ func (m *Manager) LoginWithManagedIdentity(ctx context.Context, clientID string) func (m *Manager) LoginWithServicePrincipalSecret( ctx context.Context, tenantId, clientId, clientSecret string, ) (azcore.TokenCredential, error) { - cred, err := azidentity.NewClientSecretCredential(tenantId, clientId, clientSecret, nil) + cred, err := azidentity.NewClientSecretCredential(tenantId, clientId, clientSecret, &azidentity.ClientSecretCredentialOptions{ + ClientOptions: m.authClientOptions(), + }) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } @@ -898,7 +902,9 @@ func (m *Manager) LoginWithServicePrincipalCertificate( return nil, fmt.Errorf("parsing certificate: %w", err) } - cred, err := azidentity.NewClientCertificateCredential(tenantId, clientId, certs, key, nil) + cred, err := azidentity.NewClientCertificateCredential(tenantId, clientId, certs, key, &azidentity.ClientCertificateCredentialOptions{ + ClientOptions: m.authClientOptions(), + }) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } @@ -951,12 +957,7 @@ func (m *Manager) LoginWithAzurePipelinesFederatedTokenProvider( } options := &azidentity.AzurePipelinesCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: m.httpClient, - // TODO: Inject client options instead? this can be done if we're OK - // using the default user agent string. - Cloud: m.cloud.Configuration, - }, + ClientOptions: m.authClientOptions(), } cred, err := azidentity.NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, systemAccessToken, options) diff --git a/cli/azd/pkg/auth/user_agent_client.go b/cli/azd/pkg/auth/user_agent_client.go index c8cc99363b3..5b25fb8d2e7 100644 --- a/cli/azd/pkg/auth/user_agent_client.go +++ b/cli/azd/pkg/auth/user_agent_client.go @@ -19,6 +19,9 @@ func newUserAgentClient(inner HttpClient, userAgent string) HttpClient { } func (c *userAgentClient) Do(req *http.Request) (*http.Response, error) { + if req.Header == nil { + req.Header = make(http.Header) + } if req.Header.Get("User-Agent") == "" { req.Header.Set("User-Agent", c.userAgent) } else { From 67465dc19451b1247ff71da2a170f6c172c0eceb Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 11:46:33 -0500 Subject: [PATCH 3/5] Address Copilot review: add tests, fix redundant header lookup - Add TestUserAgentClient with 4 test cases covering empty header, existing header append, empty userAgent passthrough, and nil header - Store existing User-Agent in variable to avoid redundant lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/auth/user_agent_client.go | 5 +- cli/azd/pkg/auth/user_agent_client_test.go | 86 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 cli/azd/pkg/auth/user_agent_client_test.go diff --git a/cli/azd/pkg/auth/user_agent_client.go b/cli/azd/pkg/auth/user_agent_client.go index 5b25fb8d2e7..4a3be541ea5 100644 --- a/cli/azd/pkg/auth/user_agent_client.go +++ b/cli/azd/pkg/auth/user_agent_client.go @@ -22,10 +22,11 @@ func (c *userAgentClient) Do(req *http.Request) (*http.Response, error) { if req.Header == nil { req.Header = make(http.Header) } - if req.Header.Get("User-Agent") == "" { + existingUA := req.Header.Get("User-Agent") + if existingUA == "" { req.Header.Set("User-Agent", c.userAgent) } else { - req.Header.Set("User-Agent", req.Header.Get("User-Agent")+","+c.userAgent) + req.Header.Set("User-Agent", existingUA+","+c.userAgent) } return c.inner.Do(req) } diff --git a/cli/azd/pkg/auth/user_agent_client_test.go b/cli/azd/pkg/auth/user_agent_client_test.go new file mode 100644 index 00000000000..1c36b1f1cb4 --- /dev/null +++ b/cli/azd/pkg/auth/user_agent_client_test.go @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package auth + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +type mockHttpClient struct { + lastRequest *http.Request +} + +func (m *mockHttpClient) Do(req *http.Request) (*http.Response, error) { + m.lastRequest = req + return &http.Response{StatusCode: 200}, nil +} + +func (m *mockHttpClient) CloseIdleConnections() {} + +func TestUserAgentClient(t *testing.T) { + tests := []struct { + name string + userAgent string + existingUserAgent string + nilHeader bool + expectedUserAgent string + expectWrapped bool + }{ + { + name: "SetsUserAgentWhenEmpty", + userAgent: "azdev/1.0.0", + existingUserAgent: "", + expectedUserAgent: "azdev/1.0.0", + expectWrapped: true, + }, + { + name: "AppendsToExistingUserAgent", + userAgent: "azdev/1.0.0", + existingUserAgent: "existing-agent/2.0", + expectedUserAgent: "existing-agent/2.0,azdev/1.0.0", + expectWrapped: true, + }, + { + name: "EmptyUserAgentReturnsInnerClient", + userAgent: "", + expectWrapped: false, + }, + { + name: "HandlesNilHeader", + userAgent: "azdev/1.0.0", + nilHeader: true, + expectedUserAgent: "azdev/1.0.0", + expectWrapped: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inner := &mockHttpClient{} + client := newUserAgentClient(inner, tt.userAgent) + + if !tt.expectWrapped { + // Should return the inner client unchanged + require.Equal(t, inner, client) + return + } + + req, err := http.NewRequest("GET", "https://example.com", nil) + require.NoError(t, err) + + if tt.nilHeader { + req.Header = nil + } else if tt.existingUserAgent != "" { + req.Header.Set("User-Agent", tt.existingUserAgent) + } + + _, err = client.Do(req) + require.NoError(t, err) + require.Equal(t, tt.expectedUserAgent, inner.lastRequest.Header.Get("User-Agent")) + }) + } +} From fbe801225ed490869acbf03e4f41e4adc61f7589 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 20:01:46 -0500 Subject: [PATCH 4/5] Fix lll lint violations in credential options Break long lines in NewClientSecretCredential and NewClientCertificateCredential calls by extracting options into local variables. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/auth/manager.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index 5bcc87e41f2..b768a85198f 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -874,9 +874,11 @@ func (m *Manager) LoginWithManagedIdentity(ctx context.Context, clientID string) func (m *Manager) LoginWithServicePrincipalSecret( ctx context.Context, tenantId, clientId, clientSecret string, ) (azcore.TokenCredential, error) { - cred, err := azidentity.NewClientSecretCredential(tenantId, clientId, clientSecret, &azidentity.ClientSecretCredentialOptions{ + opts := &azidentity.ClientSecretCredentialOptions{ ClientOptions: m.authClientOptions(), - }) + } + cred, err := azidentity.NewClientSecretCredential( + tenantId, clientId, clientSecret, opts) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } @@ -902,9 +904,11 @@ func (m *Manager) LoginWithServicePrincipalCertificate( return nil, fmt.Errorf("parsing certificate: %w", err) } - cred, err := azidentity.NewClientCertificateCredential(tenantId, clientId, certs, key, &azidentity.ClientCertificateCredentialOptions{ + certOpts := &azidentity.ClientCertificateCredentialOptions{ ClientOptions: m.authClientOptions(), - }) + } + cred, err := azidentity.NewClientCertificateCredential( + tenantId, clientId, certs, key, certOpts) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } From c9a7361ff9c5a1698376da7697f0b55c83c29dc5 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Fri, 20 Feb 2026 15:36:43 -0500 Subject: [PATCH 5/5] Simplify IoC registration with typed UserAgent alias Address review feedback from @wbreza: use a typed UserAgent alias registered as a singleton instead of manually overriding the IoC constructor in container.go. The NewManager constructor now accepts auth.UserAgent directly via dependency injection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 14 +++----------- cli/azd/pkg/auth/manager.go | 10 +++++++--- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 69faf0d6daa..956cf1319e1 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -634,18 +634,10 @@ func registerCommonDependencies(container *ioc.NestedContainer) { Key: key, }, nil }) - container.MustRegisterScoped(func( - configManager config.FileConfigManager, - userConfigManager config.UserConfigManager, - cloud *cloud.Cloud, - httpClient auth.HttpClient, - console input.Console, - externalAuthCfg auth.ExternalAuthConfiguration, - azCli az.AzCli, - ) (*auth.Manager, error) { - return auth.NewManager( - configManager, userConfigManager, cloud, httpClient, console, externalAuthCfg, azCli, internal.UserAgent()) + container.MustRegisterSingleton(func() auth.UserAgent { + return auth.UserAgent(internal.UserAgent()) }) + container.MustRegisterScoped(auth.NewManager) container.MustRegisterSingleton(azapi.NewUserProfileService) container.MustRegisterScoped(func(authManager *auth.Manager) middleware.CurrentUserAuthManager { return authManager diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index b768a85198f..71fa9ed63e5 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -101,6 +101,10 @@ type Manager struct { userAgent string } +// UserAgent is a typed string for the application user-agent, +// used for dependency injection. +type UserAgent string + type ExternalAuthConfiguration struct { Endpoint string Key string @@ -115,7 +119,7 @@ func NewManager( console input.Console, externalAuthCfg ExternalAuthConfiguration, azCli az.AzCli, - userAgent string, + userAgent UserAgent, ) (*Manager, error) { cfgRoot, err := config.GetUserConfigDir() if err != nil { @@ -137,7 +141,7 @@ func NewManager( return nil, fmt.Errorf("joining authority url: %w", err) } - msalClient := newUserAgentClient(httpClient, userAgent) + msalClient := newUserAgentClient(httpClient, string(userAgent)) options := []public.Option{ public.WithCache(newCache(cacheRoot)), @@ -161,7 +165,7 @@ func NewManager( console: console, externalAuthCfg: externalAuthCfg, azCli: azCli, - userAgent: userAgent, + userAgent: string(userAgent), }, nil }