From 20fc1e5bfa1a2040347810f4acc6a1cbc5d26fb2 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 24 Aug 2026 13:53:58 +0100 Subject: [PATCH 1/4] azrepos: use the GCM Entra application Azure Repos OAuth currently identifies GCM as the Visual Studio application. That client registration does not provide the redirect URIs required for broker authentication on macOS and Linux. Use GCM's dedicated first-party Entra application by default and enable broker support on all supported platforms. Retain the Visual Studio client ID as an explicit compatibility fallback while replacing the unrestricted developer overrides with a narrow, documented setting. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- docs/configuration.md | 22 +++++++++++++++ docs/environment.md | 28 +++++++++++++++++++ .../AzureDevOpsConstants.cs | 14 ++++------ .../AzureDevOpsRestApi.cs | 17 +---------- .../AzureReposHostProvider.cs | 28 +++++++++++-------- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index af5d410f41..e94cc452c7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -827,6 +827,27 @@ Credential: "git:https://bob@github.com/example/myrepo" (user = bob) --- +### credential.azreposUseLegacyClientId + +Use the legacy Visual Studio Entra application when authenticating to Azure +Repos with Microsoft identity OAuth tokens. Set this value to `true` to restore +the application identity used by earlier versions of GCM. + +The legacy application does not support broker authentication on macOS or +Linux. + +Defaults to `false`. + +#### Example + +```shell +git config --global credential.azreposUseLegacyClientId true +``` + +**Also see: [GCM_AZREPOS_USE_LEGACY_CLIENTID][gcm-azrepos-legacy-client-id]** + +--- + ### credential.azreposCredentialType Specify the type of credential the Azure Repos host provider should return. @@ -1179,6 +1200,7 @@ Defaults to disabled. [gcm-authority]: environment.md#GCM_AUTHORITY-deprecated [gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT [gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE +[gcm-azrepos-legacy-client-id]: environment.md#GCM_AZREPOS_USE_LEGACY_CLIENTID [gcm-azrepos-credentialmanagedidentity]: environment.md#GCM_AZREPOS_MANAGEDIDENTITY [gcm-azrepos-wif]: environment.md#GCM_AZREPOS_WIF [gcm-azrepos-wif-clientid]: environment.md#GCM_AZREPOS_WIF_CLIENTID diff --git a/docs/environment.md b/docs/environment.md index 44a50e4eeb..2712529b69 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -920,6 +920,33 @@ export GCM_MSAUTH_USEDEFAULTACCOUNT="false" --- +### GCM_AZREPOS_USE_LEGACY_CLIENTID + +Use the legacy Visual Studio Entra application when authenticating to Azure +Repos with Microsoft identity OAuth tokens. Set this value to `true` to restore +the application identity used by earlier versions of GCM. + +The legacy application does not support broker authentication on macOS or +Linux. + +Defaults to `false`. + +#### Windows + +```batch +SET GCM_AZREPOS_USE_LEGACY_CLIENTID="true" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_USE_LEGACY_CLIENTID="true" +``` + +**Also see: [credential.azreposUseLegacyClientId][legacy-client-id]** + +--- + ### GCM_AZREPOS_CREDENTIALTYPE Specify the type of credential the Azure Repos host provider should return. @@ -1351,6 +1378,7 @@ Defaults to disabled. [credential-authority]: configuration.md#credentialauthority-deprecated [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout [credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype +[legacy-client-id]: configuration.md#credentialazreposuselegacyclientid [credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity [credential-azrepos-wif]: configuration.md#credentialazreposworkloadfederation [credential-azrepos-wif-clientid]: configuration.md#credentialazreposworkloadfederationclientid diff --git a/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs index ac09fed130..c86060ac9c 100644 --- a/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -1,5 +1,3 @@ -using System; - namespace Microsoft.AzureRepos { internal static class AzureDevOpsConstants @@ -11,9 +9,11 @@ internal static class AzureDevOpsConstants public const string AzureDevOpsResourceId = "499b84ac-1321-427f-aa17-267ca6975798"; public static readonly string[] AzureDevOpsDefaultScopes = {$"{AzureDevOpsResourceId}/.default"}; + // The GCM first party application client ID + public const string ClientId = "d735b71b-9eee-4a4f-ad23-421660877ba6"; + // Visual Studio's client ID - // We share this to be able to consume existing access tokens from the VS caches - public const string AadClientId = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1"; + public const string LegacyClientId = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1"; public const string VstsHostSuffix = ".visualstudio.com"; public const string AzureDevOpsHost = "dev.azure.com"; @@ -34,8 +34,7 @@ public static class PersonalAccessTokenScopes public static class EnvironmentVariables { - public const string DevAadClientId = "GCM_DEV_AZREPOS_CLIENTID"; - public const string DevAadAuthorityBaseUri = "GCM_DEV_AZREPOS_AUTHORITYBASEURI"; + public const string UseLegacyClientId = "GCM_AZREPOS_USE_LEGACY_CLIENTID"; public const string CredentialType = "GCM_AZREPOS_CREDENTIALTYPE"; public const string ServicePrincipalId = "GCM_AZREPOS_SERVICE_PRINCIPAL"; public const string ServicePrincipalSecret = "GCM_AZREPOS_SP_SECRET"; @@ -54,8 +53,7 @@ public static class GitConfiguration { public static class Credential { - public const string DevAadClientId = "azreposDevClientId"; - public const string DevAadAuthorityBaseUri = "azreposDevAuthorityBaseUri"; + public const string UseLegacyClientId = "azreposUseLegacyClientId"; public const string CredentialType = "azreposCredentialType"; public const string AzureAuthority = "azureAuthority"; public const string ServicePrincipal = "azreposServicePrincipal"; diff --git a/src/Microsoft.AzureRepos/AzureDevOpsRestApi.cs b/src/Microsoft.AzureRepos/AzureDevOpsRestApi.cs index 0f4ab8497c..9130a339d3 100644 --- a/src/Microsoft.AzureRepos/AzureDevOpsRestApi.cs +++ b/src/Microsoft.AzureRepos/AzureDevOpsRestApi.cs @@ -31,7 +31,7 @@ public async Task GetAuthorityAsync(Uri organizationUri) { EnsureArgument.AbsoluteUri(organizationUri, nameof(organizationUri)); - Uri authorityBase = GetAuthorityBaseUri(); + Uri authorityBase = new(AzureDevOpsConstants.AadAuthorityBaseUrl); var commonAuthority = new Uri(authorityBase, "common"); // We should be using "/common" or "/consumer" as the authority for MSA but since @@ -88,21 +88,6 @@ public async Task GetAuthorityAsync(Uri organizationUri) return commonAuthority.ToString(); } - private Uri GetAuthorityBaseUri() - { - // Check for developer override value - if (_context.Settings.TryGetSetting( - AzureDevOpsConstants.EnvironmentVariables.DevAadAuthorityBaseUri, - Constants.GitConfiguration.Credential.SectionName, AzureDevOpsConstants.GitConfiguration.Credential.DevAadAuthorityBaseUri, - out string redirectUriStr) && - Uri.TryCreate(redirectUriStr, UriKind.Absolute, out Uri authorityBase)) - { - return authorityBase; - } - - return new Uri(AzureDevOpsConstants.AadAuthorityBaseUrl); - } - public async Task CreatePersonalAccessTokenAsync(Uri organizationUri, string accessToken, IEnumerable scopes) { const string sessionTokenUrl = "_apis/token/sessiontokens?api-version=1.0&tokentype=compact"; diff --git a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs index ff26f772fe..e68eefce91 100644 --- a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -402,31 +402,37 @@ private async Task GetEntraAccessTokenAsync(GitReque private PublicClientConfig GetEntraConfig() { + (string clientId, bool isLegacy) = GetClientAppInfo(); + + _context.Trace.WriteLine(isLegacy + ? $"Using legacy Entra client ID '{clientId}'" + : $"Using new Entra client ID '{clientId}'"); + return new PublicClientConfig { - ClientId = GetClientId(), + ClientId = clientId, IsMsaPassthroughEnabled = true, UseSharedCache = true, SupportsWindowsBroker = true, - // TODO: enable once our app registration has the appropriate redirect URLs - //SupportsMacBroker = true, - //SupportsLinuxBroker = true, + // Only the new client ID supports broker on Mac and Linux + SupportsMacBroker = !isLegacy, + SupportsLinuxBroker = !isLegacy, }; } - private string GetClientId() + private (string clientId, bool isLegacy) GetClientAppInfo() { - // Check for developer override value + // Check for override to use the legacy client ID if (_context.Settings.TryGetSetting( - AzureDevOpsConstants.EnvironmentVariables.DevAadClientId, + AzureDevOpsConstants.EnvironmentVariables.UseLegacyClientId, Constants.GitConfiguration.Credential.SectionName, - AzureDevOpsConstants.GitConfiguration.Credential.DevAadClientId, - out string clientId)) + AzureDevOpsConstants.GitConfiguration.Credential.UseLegacyClientId, + out string str) && str.IsTruthy()) { - return clientId; + return (AzureDevOpsConstants.LegacyClientId, true); } - return AzureDevOpsConstants.AadClientId; + return (AzureDevOpsConstants.ClientId, false); } /// From 03ac5e7c7bd9a6496fcd5fffe670df493b9abeab Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 24 Aug 2026 14:24:55 +0100 Subject: [PATCH 2/4] entra: expose public client configuration Consumer currently cannot determine which application registration or capabilities back an Entra authentication instance after it has been constructed. Azure Repos needs this information to distinguish its new client application from the legacy fallback. Expose the immutable public client configuration through the authentication interface and use it as the implementation's single source of configuration state. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- .../Entra/EntraAuthentication.Caching.cs | 2 +- .../Entra/EntraAuthentication.PublicClient.cs | 23 ++++++++++--------- .../Entra/EntraAuthentication.cs | 2 +- .../Entra/IEntraAuthentication.cs | 6 +++++ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs b/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs index 3771ed81cd..f049ff5277 100644 --- a/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs +++ b/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs @@ -103,7 +103,7 @@ internal StorageCreationProperties CreateUserTokenCacheProps(bool useLinuxFallba // If we are using the shared Microsoft Developer cache there are a different set of // file paths, names, and keychain/keyring attributes to use. // The shared cache is used by other Microsoft developer tools such as the Azure PowerShell CLI. - if (_publicClientConfig.UseSharedCache) + if (PublicClientConfig.UseSharedCache) { Context.Trace.WriteLine("Using shared Microsoft Developer MSAL cache"); diff --git a/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs b/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs index f0d79e40b6..ca8e28f234 100644 --- a/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs +++ b/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs @@ -68,7 +68,8 @@ public record PublicClientConfig public partial class EntraAuthentication { private const string MacBrokerRedirectUrl = "msauth.com.msauth.unsignedapp://auth"; - private readonly PublicClientConfig _publicClientConfig; + + public PublicClientConfig PublicClientConfig { get; } public async Task GetInteractionModeAsync(CancellationToken ct = default) { @@ -219,7 +220,7 @@ private async Task GetTokenForUserSilentAsync( try { return await app.AcquireTokenSilent(scopes, msalAccount) - .WithMsaPassthroughTransfer(_publicClientConfig.IsMsaPassthroughEnabled, msalAccount) + .WithMsaPassthroughTransfer(PublicClientConfig.IsMsaPassthroughEnabled, msalAccount) .ExecuteAsync(ct); } catch (MsalUiRequiredException) @@ -439,7 +440,7 @@ private Task ShowDeviceCodeAsync(DeviceCodeResult dcr) /// True if the broker will be used for this applications build using this builder. private PublicClientApplicationBuilder GetPublicAppBuilder(out bool useBroker) { - if (_publicClientConfig is null) + if (PublicClientConfig is null) { throw new InvalidOperationException( "Public client configuration is required for user authentication."); @@ -448,7 +449,7 @@ private PublicClientApplicationBuilder GetPublicAppBuilder(out bool useBroker) if (_publicBuilder is null) { Context.Trace.WriteLine("Creating public client application builder..."); - var builder = PublicClientApplicationBuilder.Create(_publicClientConfig.ClientId) + var builder = PublicClientApplicationBuilder.Create(PublicClientConfig.ClientId) .WithHttpClientFactory(_httpFactory) .WithTraceLogging(Context) .WithLegacyCacheCompatibility(false) @@ -459,9 +460,9 @@ private PublicClientApplicationBuilder GetPublicAppBuilder(out bool useBroker) if (Context.SessionManager.IsDesktopSession && IsBrokerEnabled()) { // Check that the app config supports the broker on this platform - if (_publicClientConfig.SupportsWindowsBroker && PlatformUtils.IsWindows() || - _publicClientConfig.SupportsMacBroker && PlatformUtils.IsMacOS() || - _publicClientConfig.SupportsLinuxBroker && PlatformUtils.IsLinux()) + if (PublicClientConfig.SupportsWindowsBroker && PlatformUtils.IsWindows() || + PublicClientConfig.SupportsMacBroker && PlatformUtils.IsMacOS() || + PublicClientConfig.SupportsLinuxBroker && PlatformUtils.IsLinux()) { Context.Trace.WriteLine("Broker is supported by the app and enabled by the user."); @@ -514,20 +515,20 @@ private BrokerOptions GetBrokerOptions() { var oses = BrokerOptions.OperatingSystems.None; - if (_publicClientConfig.SupportsWindowsBroker) + if (PublicClientConfig.SupportsWindowsBroker) oses |= BrokerOptions.OperatingSystems.Windows; - if (_publicClientConfig.SupportsMacBroker) + if (PublicClientConfig.SupportsMacBroker) oses |= BrokerOptions.OperatingSystems.OSX; - if (_publicClientConfig.SupportsLinuxBroker) + if (PublicClientConfig.SupportsLinuxBroker) oses |= BrokerOptions.OperatingSystems.Linux; return new BrokerOptions(oses) { Title = "Git Credential Manager", ListOperatingSystemAccounts = true, - MsaPassthrough = _publicClientConfig.IsMsaPassthroughEnabled + MsaPassthrough = PublicClientConfig.IsMsaPassthroughEnabled }; } diff --git a/src/Core/Authentication/Entra/EntraAuthentication.cs b/src/Core/Authentication/Entra/EntraAuthentication.cs index abedd1d245..ff76197aaf 100644 --- a/src/Core/Authentication/Entra/EntraAuthentication.cs +++ b/src/Core/Authentication/Entra/EntraAuthentication.cs @@ -21,7 +21,7 @@ public partial class EntraAuthentication : AuthenticationBase, IEntraAuthenticat public EntraAuthentication(ICommandContext context, PublicClientConfig publicClientConfig = null) : base(context) { - _publicClientConfig = publicClientConfig; + PublicClientConfig = publicClientConfig; _httpFactory = new MsalHttpClientFactoryAdaptor(context.HttpClientFactory); } diff --git a/src/Core/Authentication/Entra/IEntraAuthentication.cs b/src/Core/Authentication/Entra/IEntraAuthentication.cs index 6b4b1f8916..cd70095c53 100644 --- a/src/Core/Authentication/Entra/IEntraAuthentication.cs +++ b/src/Core/Authentication/Entra/IEntraAuthentication.cs @@ -6,6 +6,12 @@ namespace GitCredentialManager.Authentication.Entra; public interface IEntraAuthentication { + /// + /// The public client configuration used for authentication. + /// + /// If this property is null then public client APIs cannot be called. + PublicClientConfig PublicClientConfig { get; } + /// /// Ask the user which interaction mode they would like to use for authentication. /// From defc3c343a6464f1ecc6a06c6289d5fa7ebff84b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 24 Aug 2026 14:30:16 +0100 Subject: [PATCH 3/4] azrepos: retry OAuth with the legacy client The dedicated GCM Entra application may encounter authentication failures that did not occur with the legacy Visual Studio application. Failing immediately would block affected users during the transition without providing a compatibility path. When user OAuth fails through the new application, report the failure and retry once with the legacy client. Do not retry requests already using the legacy application, avoiding an unbounded fallback loop. Only need to retry user/public client flows and not SP/WIF/MI based auth since they do not use the public client ID. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- .../AzureReposHostProvider.cs | 87 ++++++++++++------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs index e68eefce91..946c6cd576 100644 --- a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -9,6 +9,7 @@ using GitCredentialManager; using GitCredentialManager.Authentication.Entra; using GitCredentialManager.Commands; +using Microsoft.Identity.Client; using KnownGitCfg = GitCredentialManager.Constants.GitConfiguration; namespace Microsoft.AzureRepos @@ -19,39 +20,45 @@ public class AzureReposHostProvider : DisposableObject, IHostProvider, IConfigur private readonly IAzureDevOpsRestApi _azDevOps; private readonly IAzureDevOpsAuthorityCache _authorityCache; private readonly IAzureReposBindingManager _bindingManager; - private readonly Lazy _entraAuth; + private readonly Func _entraAuthFactory; - public AzureReposHostProvider(ICommandContext context) - : this(context, new AzureDevOpsRestApi(context), + private Lazy _entraAuth; + + private void ResetEntraAuth(bool forceLegacyClientId = false) + { + _entraAuth = new Lazy(() => _entraAuthFactory(GetEntraConfig(forceLegacyClientId))); + } + + public AzureReposHostProvider(ICommandContext context) : + this(context, new AzureDevOpsRestApi(context), config => new EntraAuthentication(context, config), new AzureDevOpsAuthorityCache(context), new AzureReposBindingManager(context)) { } - public AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDevOps, - IAzureDevOpsAuthorityCache authorityCache, + internal AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDevOps, + IEntraAuthentication entraAuth, IAzureDevOpsAuthorityCache authorityCache, IAzureReposBindingManager bindingManager) + : this(context, azDevOps, _ => entraAuth, authorityCache, bindingManager) + { + EnsureArgument.NotNull(entraAuth, nameof(entraAuth)); + } + + internal AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDevOps, + Func entraAuthFactory, + IAzureDevOpsAuthorityCache authorityCache, IAzureReposBindingManager bindingManager) { EnsureArgument.NotNull(context, nameof(context)); EnsureArgument.NotNull(azDevOps, nameof(azDevOps)); EnsureArgument.NotNull(authorityCache, nameof(authorityCache)); EnsureArgument.NotNull(bindingManager, nameof(bindingManager)); + EnsureArgument.NotNull(entraAuthFactory, nameof(entraAuthFactory)); _context = context; _azDevOps = azDevOps; _authorityCache = authorityCache; _bindingManager = bindingManager; - _entraAuth = new Lazy( - () => new EntraAuthentication(_context, GetEntraConfig())); - } - - public AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDevOps, - IEntraAuthentication entraAuth, IAzureDevOpsAuthorityCache authorityCache, - IAzureReposBindingManager bindingManager) - : this(context, azDevOps, authorityCache, bindingManager) - { - EnsureArgument.NotNull(entraAuth, nameof(entraAuth)); - - _entraAuth = new Lazy(() => entraAuth); + _entraAuthFactory = entraAuthFactory; + ResetEntraAuth(); } #region IHostProvider @@ -116,6 +123,30 @@ public async Task GetCredentialAsync(GitRequest request) ); } + try + { + return await GetUserCredentialAsync(request); + } + // If we are using the new client ID, and we get an MSAL exception, let's retry using the + // legacy client ID and log a warning; asking the user to report the problem. + catch (MsalException msalEx) when (msalEx.ErrorCode != MsalError.AuthenticationCanceledError && + _entraAuth.Value.PublicClientConfig.ClientId != + AzureDevOpsConstants.LegacyClientId) + { + _context.Console.WriteError($"Failed to acquire Entra access token: {msalEx.Message}"); + _context.Console.WriteInfo($"Please report this failure at {Constants.HelpUrls.GcmNewIssue}"); + _context.Console.WriteInfo("Trying again with the legacy Entra client app..."); + + // Recreate the lazy entra auth component forcing the legacy client ID + ResetEntraAuth(forceLegacyClientId: true); + + // Try again! + return await GetUserCredentialAsync(request); + } + } + + private async Task GetUserCredentialAsync(GitRequest request) + { if (UsePersonalAccessTokens()) { Uri remoteWithUserUri = request.GetRemoteUri(includeUser: true); @@ -141,14 +172,12 @@ public async Task GetCredentialAsync(GitRequest request) return new GitResponse(credential); } - else - { - // Include the username request here so that we may use it as an override - // for user account lookups when getting Entra access tokens. - var entraResult = await GetEntraAccessTokenAsync(request); - var entraCredential = new GitCredential(entraResult.Account.UserName, entraResult.AccessToken); - return new GitResponse(entraCredential); - } + + // Include the username request here so that we may use it as an override + // for user account lookups when getting Entra access tokens. + var entraResult = await GetEntraAccessTokenAsync(request); + var entraCredential = new GitCredential(entraResult.Account.UserName, entraResult.AccessToken); + return new GitResponse(entraCredential); } public Task StoreCredentialAsync(GitRequest request) @@ -400,9 +429,9 @@ private async Task GetEntraAccessTokenAsync(GitReque return false; } - private PublicClientConfig GetEntraConfig() + private PublicClientConfig GetEntraConfig(bool forceLegacyClientId) { - (string clientId, bool isLegacy) = GetClientAppInfo(); + (string clientId, bool isLegacy) = GetClientAppInfo(forceLegacyClientId); _context.Trace.WriteLine(isLegacy ? $"Using legacy Entra client ID '{clientId}'" @@ -420,10 +449,10 @@ private PublicClientConfig GetEntraConfig() }; } - private (string clientId, bool isLegacy) GetClientAppInfo() + private (string clientId, bool isLegacy) GetClientAppInfo(bool forceLegacyClientId) { // Check for override to use the legacy client ID - if (_context.Settings.TryGetSetting( + if (forceLegacyClientId || _context.Settings.TryGetSetting( AzureDevOpsConstants.EnvironmentVariables.UseLegacyClientId, Constants.GitConfiguration.Credential.SectionName, AzureDevOpsConstants.GitConfiguration.Credential.UseLegacyClientId, From baaa2adafbf89fbbf66d4be3c9eb66331f945882 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 26 Aug 2026 17:47:23 +0100 Subject: [PATCH 4/4] azrepos: test legacy client fallback The compatibility retry is the recovery path for authentication failures introduced by the new GCM Entra application. Without direct coverage, regressions would only surface for users during live authentication. Exercise both OAuth and PAT credential generation through a failed new-client attempt and successful legacy retry. Also ensure an already-legacy client propagates the failure rather than retrying indefinitely. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- .../AzureReposHostProviderTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index 786e24a655..233ccfedcf 100644 --- a/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -6,6 +6,7 @@ using GitCredentialManager.Authentication.Entra; using GitCredentialManager.Tests; using GitCredentialManager.Tests.Objects; +using Microsoft.Identity.Client; using Moq; using Xunit; @@ -444,6 +445,133 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit Assert.Equal(accessToken, credential.Password); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AzureReposProvider_GetCredentialAsync_MsalFailure_RetriesWithLegacyClient(bool usePat) + { + var request = new GitRequest(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + var expectedOrgUri = new Uri("https://dev.azure.com/org"); + var authorityUrl = "https://login.microsoftonline.com/common"; + var accessToken = "ACCESS-TOKEN"; + var personalAccessToken = "PERSONAL-ACCESS-TOKEN"; + var account = "john.doe"; + var authResult = CreateAuthResult(account, accessToken); + var msalException = new MsalException("test_error", "Test failure"); + var clientIds = new List(); + + var context = new TestCommandContext(); + if (!usePat) + { + context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.CredentialType] = + AzureDevOpsConstants.OAuthCredentialType; + } + + var azDevOpsMock = new Mock(MockBehavior.Strict); + if (usePat) + { + azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); + azDevOpsMock.Setup(x => x.CreatePersonalAccessTokenAsync( + expectedOrgUri, accessToken, It.IsAny>())) + .ReturnsAsync(personalAccessToken); + } + + var newEntraAuthMock = new Mock(MockBehavior.Strict); + newEntraAuthMock.SetupGet(x => x.PublicClientConfig) + .Returns(new PublicClientConfig { ClientId = AzureDevOpsConstants.ClientId }); + newEntraAuthMock.Setup(x => x.GetTokenForUserAsync( + AzureDevOpsConstants.AzureDevOpsDefaultScopes, authorityUrl, null, + InteractionMode.Auto, CancellationToken.None)) + .ThrowsAsync(msalException); + + var legacyEntraAuthMock = new Mock(MockBehavior.Strict); + legacyEntraAuthMock.SetupGet(x => x.PublicClientConfig) + .Returns(new PublicClientConfig { ClientId = AzureDevOpsConstants.LegacyClientId }); + legacyEntraAuthMock.Setup(x => x.GetTokenForUserAsync( + AzureDevOpsConstants.AzureDevOpsDefaultScopes, authorityUrl, null, + InteractionMode.Auto, CancellationToken.None)) + .ReturnsAsync(authResult); + + IEntraAuthentication EntraAuthFactory(PublicClientConfig config) + { + clientIds.Add(config.ClientId); + return config.ClientId == AzureDevOpsConstants.LegacyClientId + ? legacyEntraAuthMock.Object + : newEntraAuthMock.Object; + } + + var authorityCacheMock = new Mock(MockBehavior.Strict); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); + + var userMgrMock = new Mock(MockBehavior.Strict); + userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null); + + var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, EntraAuthFactory, + authorityCacheMock.Object, userMgrMock.Object); + + GitResponse result = await provider.GetCredentialAsync(request); + + Assert.Equal(account, result.Credential.Account); + Assert.Equal(usePat ? personalAccessToken : accessToken, result.Credential.Password); + Assert.Equal( + new[] { AzureDevOpsConstants.ClientId, AzureDevOpsConstants.LegacyClientId }, + clientIds); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_LegacyClientMsalFailure_DoesNotRetry() + { + var request = new GitRequest(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + var authorityUrl = "https://login.microsoftonline.com/common"; + var msalException = new MsalException("test_error", "Test failure"); + var clientIds = new List(); + + var context = new TestCommandContext(); + context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.CredentialType] = + AzureDevOpsConstants.OAuthCredentialType; + context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.UseLegacyClientId] = "true"; + + var entraAuthMock = new Mock(MockBehavior.Strict); + entraAuthMock.Setup(x => x.GetTokenForUserAsync( + AzureDevOpsConstants.AzureDevOpsDefaultScopes, authorityUrl, null, + InteractionMode.Auto, CancellationToken.None)) + .ThrowsAsync(msalException); + + IEntraAuthentication EntraAuthFactory(PublicClientConfig config) + { + clientIds.Add(config.ClientId); + entraAuthMock.SetupGet(x => x.PublicClientConfig).Returns(config); + return entraAuthMock.Object; + } + + var authorityCacheMock = new Mock(MockBehavior.Strict); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); + + var userMgrMock = new Mock(MockBehavior.Strict); + userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null); + + var provider = new AzureReposHostProvider(context, Mock.Of(), + EntraAuthFactory, authorityCacheMock.Object, userMgrMock.Object); + + MsalException exception = await Assert.ThrowsAsync( + () => provider.GetCredentialAsync(request)); + + Assert.Same(msalException, exception); + Assert.Equal(new[] { AzureDevOpsConstants.LegacyClientId }, clientIds); + } + [Fact] public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_NoExistingPat_GeneratesCredential() {