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/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. /// 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() { 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..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,33 +429,39 @@ private async Task GetEntraAccessTokenAsync(GitReque return false; } - private PublicClientConfig GetEntraConfig() + private PublicClientConfig GetEntraConfig(bool forceLegacyClientId) { + (string clientId, bool isLegacy) = GetClientAppInfo(forceLegacyClientId); + + _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(bool forceLegacyClientId) { - // Check for developer override value - if (_context.Settings.TryGetSetting( - AzureDevOpsConstants.EnvironmentVariables.DevAadClientId, + // Check for override to use the legacy client ID + if (forceLegacyClientId || _context.Settings.TryGetSetting( + 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); } ///