Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
23 changes: 12 additions & 11 deletions src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InteractionMode> GetInteractionModeAsync(CancellationToken ct = default)
{
Expand Down Expand Up @@ -219,7 +220,7 @@ private async Task<AuthenticationResult> GetTokenForUserSilentAsync(
try
{
return await app.AcquireTokenSilent(scopes, msalAccount)
.WithMsaPassthroughTransfer(_publicClientConfig.IsMsaPassthroughEnabled, msalAccount)
.WithMsaPassthroughTransfer(PublicClientConfig.IsMsaPassthroughEnabled, msalAccount)
.ExecuteAsync(ct);
}
catch (MsalUiRequiredException)
Expand Down Expand Up @@ -439,7 +440,7 @@ private Task ShowDeviceCodeAsync(DeviceCodeResult dcr)
/// <param name="useBroker">True if the broker will be used for this applications build using this builder.</param>
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.");
Expand All @@ -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)
Expand All @@ -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.");

Expand Down Expand Up @@ -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
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/Core/Authentication/Entra/EntraAuthentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
6 changes: 6 additions & 0 deletions src/Core/Authentication/Entra/IEntraAuthentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ namespace GitCredentialManager.Authentication.Entra;

public interface IEntraAuthentication
{
/// <summary>
/// The public client configuration used for authentication.
/// </summary>
/// <remarks>If this property is null then public client APIs cannot be called.</remarks>
PublicClientConfig PublicClientConfig { get; }

/// <summary>
/// Ask the user which interaction mode they would like to use for authentication.
/// </summary>
Expand Down
128 changes: 128 additions & 0 deletions src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using GitCredentialManager.Authentication.Entra;
using GitCredentialManager.Tests;
using GitCredentialManager.Tests.Objects;
using Microsoft.Identity.Client;
using Moq;
using Xunit;

Expand Down Expand Up @@ -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<string, string>
{
["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<string>();

var context = new TestCommandContext();
if (!usePat)
{
context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.CredentialType] =
AzureDevOpsConstants.OAuthCredentialType;
}

var azDevOpsMock = new Mock<IAzureDevOpsRestApi>(MockBehavior.Strict);
if (usePat)
{
azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl);
azDevOpsMock.Setup(x => x.CreatePersonalAccessTokenAsync(
expectedOrgUri, accessToken, It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(personalAccessToken);
}

var newEntraAuthMock = new Mock<IEntraAuthentication>(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<IEntraAuthentication>(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<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl);

var userMgrMock = new Mock<IAzureReposBindingManager>(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<string, string>
{
["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<string>();

var context = new TestCommandContext();
context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.CredentialType] =
AzureDevOpsConstants.OAuthCredentialType;
context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.UseLegacyClientId] = "true";

var entraAuthMock = new Mock<IEntraAuthentication>(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<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl);

var userMgrMock = new Mock<IAzureReposBindingManager>(MockBehavior.Strict);
userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null);

var provider = new AzureReposHostProvider(context, Mock.Of<IAzureDevOpsRestApi>(),
EntraAuthFactory, authorityCacheMock.Object, userMgrMock.Object);

MsalException exception = await Assert.ThrowsAsync<MsalException>(
() => provider.GetCredentialAsync(request));

Assert.Same(msalException, exception);
Assert.Equal(new[] { AzureDevOpsConstants.LegacyClientId }, clientIds);
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_NoExistingPat_GeneratesCredential()
{
Expand Down
14 changes: 6 additions & 8 deletions src/Microsoft.AzureRepos/AzureDevOpsConstants.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System;

namespace Microsoft.AzureRepos
{
internal static class AzureDevOpsConstants
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand Down
17 changes: 1 addition & 16 deletions src/Microsoft.AzureRepos/AzureDevOpsRestApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task<string> 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
Expand Down Expand Up @@ -88,21 +88,6 @@ public async Task<string> 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<string> CreatePersonalAccessTokenAsync(Uri organizationUri, string accessToken, IEnumerable<string> scopes)
{
const string sessionTokenUrl = "_apis/token/sessiontokens?api-version=1.0&tokentype=compact";
Expand Down
Loading
Loading