diff --git a/docs/azrepos-users-and-tokens.md b/docs/azrepos-users-and-tokens.md index d5b58186bc..4f9fe186d7 100644 --- a/docs/azrepos-users-and-tokens.md +++ b/docs/azrepos-users-and-tokens.md @@ -42,6 +42,11 @@ including the Visual Studio IDE and Azure CLI. This means that as long as you're using Git or one of these tools with the same account, you'll never need to re-authenticate due to expired tokens! +GCM uses the shared Microsoft developer tooling token cache by default. To keep +Azure Repos OAuth tokens in a GCM-specific cache instead, set +[`credential.azreposUseMicrosoftSharedCache`][credential-azrepos-shared-cache] +or [`GCM_AZREPOS_USE_MSFT_CACHE`][gcm-azrepos-shared-cache] to `false`. + #### User accounts In versions of Git Credential Manager that support Microsoft identity OAuth @@ -220,6 +225,8 @@ fabrikam: [azure-devops-pats]: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page [credential-azreposCredentialType]: configuration.md#credentialazreposcredentialtype +[credential-azrepos-shared-cache]: configuration.md#credentialazreposusemicrosoftsharedcache [gcm-azrepos-credential-type]: environment.md#GCM_AZREPOS_CREDENTIALTYPE +[gcm-azrepos-shared-cache]: environment.md#GCM_AZREPOS_USE_MSFT_CACHE [azure-devops-api]: https://docs.microsoft.com/en-gb/rest/api/azure/devops/tokens/pats [rfc3986-s321]: https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1 diff --git a/docs/configuration.md b/docs/configuration.md index af5d410f41..dec68b0e13 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -827,6 +827,27 @@ Credential: "git:https://bob@github.com/example/myrepo" (user = bob) --- +### credential.azreposUseMicrosoftSharedCache + +Use the token cache shared by Microsoft developer tools when authenticating to +Azure Repos with Microsoft identity OAuth tokens. This allows GCM to reuse +authentication performed by tools such as Visual Studio and Azure CLI, and +allows those tools to reuse authentication performed by GCM. + +Set this value to `false` to use a GCM-specific token cache instead. + +Defaults to `true`. + +#### Example + +```shell +git config --global credential.azreposUseMicrosoftSharedCache false +``` + +**Also see: [GCM_AZREPOS_USE_MSFT_CACHE][gcm-azrepos-shared-cache]** + +--- + ### 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-shared-cache]: environment.md#GCM_AZREPOS_USE_MSFT_CACHE [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..6e10ebaae3 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -920,6 +920,33 @@ export GCM_MSAUTH_USEDEFAULTACCOUNT="false" --- +### GCM_AZREPOS_USE_MSFT_CACHE + +Use the token cache shared by Microsoft developer tools when authenticating to +Azure Repos with Microsoft identity OAuth tokens. This allows GCM to reuse +authentication performed by tools such as Visual Studio and Azure CLI, and +allows those tools to reuse authentication performed by GCM. + +Set this value to `false` to use a GCM-specific token cache instead. + +Defaults to `true`. + +#### Windows + +```batch +SET GCM_AZREPOS_USE_MSFT_CACHE=false +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_USE_MSFT_CACHE="false" +``` + +**Also see: [credential.azreposUseMicrosoftSharedCache][shared-cache]** + +--- + ### 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 +[shared-cache]: configuration.md#credentialazreposusemicrosoftsharedcache [credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity [credential-azrepos-wif]: configuration.md#credentialazreposworkloadfederation [credential-azrepos-wif-clientid]: configuration.md#credentialazreposworkloadfederationclientid diff --git a/src/Core/Commands/DiagnoseCommand.cs b/src/Core/Commands/DiagnoseCommand.cs index 3603d073b1..1a1e7d4042 100644 --- a/src/Core/Commands/DiagnoseCommand.cs +++ b/src/Core/Commands/DiagnoseCommand.cs @@ -114,15 +114,7 @@ private async Task ExecuteAsync(string output) DiagnosticResult result = await diagnostic.RunAsync(); fullLog.WriteLine("Success: {0}", result.IsSuccess); - if (result.Exception is null) - { - fullLog.WriteLine("Exception: None"); - } - else - { - fullLog.WriteLine("Exception:"); - fullLog.WriteLine(result.Exception.ToString()); - } + WriteException(fullLog, result.Exception); fullLog.WriteLine("Log:"); fullLog.WriteLine(result.DiagnosticLog); @@ -195,6 +187,29 @@ private async Task ExecuteAsync(string output) return numFailed; } + private void WriteException(StreamWriter log, Exception exception) + { + if (exception is null) + { + log.WriteLine("Exception: None"); + return; + } + + if (exception is AggregateException aex) + { + log.WriteLine("Exception: AggregateException"); + log.WriteLine("InnerExceptions (flattened):"); + foreach (var inner in aex.Flatten().InnerExceptions) + { + log.WriteLine(inner.ToString()); + } + } + else + { + log.WriteLine("Exception: {0}", exception); + } + } + private static class ConsoleEx { public static void WriteLineIndent(string str) diff --git a/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs b/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs index aea77c4b5e..9c4f211c0b 100644 --- a/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs +++ b/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs @@ -15,48 +15,73 @@ public EntraAuthenticationDiagnostic(ICommandContext context) protected override async Task RunInternalAsync(StringBuilder log, IList additionalFiles) { - var entraAuth = new EntraAuthentication(CommandContext, new PublicClientConfig - { - UseSharedCache = true, - }); + var failures = new List(); - log.Append("Gathering MSAL token cache data..."); - StorageCreationProperties cacheProps = entraAuth.CreateUserTokenCacheProps(true); - log.AppendLine(" OK"); - log.AppendLine($"CacheDirectory: {cacheProps.CacheDirectory}"); - log.AppendLine($"CacheFileName: {cacheProps.CacheFileName}"); - log.AppendLine($"CacheFilePath: {cacheProps.CacheFilePath}"); + await RunCacheDiagnosticAsync("Shared Microsoft developer tools", true, log, failures); + log.AppendLine(); + await RunCacheDiagnosticAsync("Git Credential Manager", false, log, failures); - if (PlatformUtils.IsMacOS()) + if (failures.Count == 1) { - log.AppendLine($"MacKeyChainAccountName: {cacheProps.MacKeyChainAccountName}"); - log.AppendLine($"MacKeyChainServiceName: {cacheProps.MacKeyChainServiceName}"); + throw failures[0]; } - else if (PlatformUtils.IsLinux()) + + if (failures.Count > 1) { - log.AppendLine($"KeyringCollection: {cacheProps.KeyringCollection}"); - log.AppendLine($"KeyringSchemaName: {cacheProps.KeyringSchemaName}"); - log.AppendLine($"KeyringSecretLabel: {cacheProps.KeyringSecretLabel}"); - log.AppendLine($"KeyringAttribute1: ({cacheProps.KeyringAttribute1.Key},{cacheProps.KeyringAttribute1.Value})"); - log.AppendLine($"KeyringAttribute2: ({cacheProps.KeyringAttribute2.Key},{cacheProps.KeyringAttribute2.Value})"); + throw new AggregateException("Multiple MSAL token cache diagnostics failed.", failures); } - log.Append("Creating cache helper..."); - var cacheHelper = await MsalCacheHelper.CreateAsync(cacheProps); - log.AppendLine(" OK"); + return true; + } + + private async Task RunCacheDiagnosticAsync( + string cacheName, + bool useSharedCache, + StringBuilder log, + ICollection failures) + { + log.AppendLine($"{cacheName} cache"); + + var entraAuth = new EntraAuthentication(CommandContext, new PublicClientConfig + { + UseSharedCache = useSharedCache, + }); + try { + log.Append("Gathering MSAL token cache data..."); + StorageCreationProperties cacheProps = entraAuth.CreateUserTokenCacheProps(true); + log.AppendLine(" OK"); + log.AppendLine($"CacheDirectory: {cacheProps.CacheDirectory}"); + log.AppendLine($"CacheFileName: {cacheProps.CacheFileName}"); + log.AppendLine($"CacheFilePath: {cacheProps.CacheFilePath}"); + + if (PlatformUtils.IsMacOS()) + { + log.AppendLine($"MacKeyChainAccountName: {cacheProps.MacKeyChainAccountName}"); + log.AppendLine($"MacKeyChainServiceName: {cacheProps.MacKeyChainServiceName}"); + } + else if (PlatformUtils.IsLinux()) + { + log.AppendLine($"KeyringCollection: {cacheProps.KeyringCollection}"); + log.AppendLine($"KeyringSchemaName: {cacheProps.KeyringSchemaName}"); + log.AppendLine($"KeyringSecretLabel: {cacheProps.KeyringSecretLabel}"); + log.AppendLine($"KeyringAttribute1: ({cacheProps.KeyringAttribute1.Key},{cacheProps.KeyringAttribute1.Value})"); + log.AppendLine($"KeyringAttribute2: ({cacheProps.KeyringAttribute2.Key},{cacheProps.KeyringAttribute2.Value})"); + } + + log.Append("Creating cache helper..."); + var cacheHelper = await MsalCacheHelper.CreateAsync(cacheProps); + log.AppendLine(" OK"); log.Append("Verifying MSAL token cache persistence..."); cacheHelper.VerifyPersistence(); log.AppendLine(" OK"); } - catch (Exception) + catch (Exception ex) { log.AppendLine(" Failed"); - throw; + failures.Add(new Exception($"{cacheName} cache diagnostic failed.", ex)); } - - return true; } } } diff --git a/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index 786e24a655..79a34ccd75 100644 --- a/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -17,8 +17,39 @@ public class AzureReposHostProviderTests $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; private static readonly string AzDevUseHttpPathKey = $"{Constants.GitConfiguration.Credential.SectionName}.https://dev.azure.com.{Constants.GitConfiguration.Credential.UseHttpPath}"; + private static readonly string AzDevUseSharedCacheKey = + $"{Constants.GitConfiguration.Credential.SectionName}.{AzureDevOpsConstants.GitConfiguration.Credential.UseSharedCache}"; private static readonly string OrgName = "org"; + [Fact] + public void AzureReposProvider_GetUseSharedCache_NoConfiguration_ReturnsTrue() + { + var provider = new AzureReposHostProvider(new TestCommandContext()); + + Assert.True(provider.GetUseSharedCache()); + } + + [Fact] + public void AzureReposProvider_GetUseSharedCache_GitConfigFalse_ReturnsFalse() + { + var context = new TestCommandContext(); + context.Git.Configuration.Global[AzDevUseSharedCacheKey] = new List {"false"}; + var provider = new AzureReposHostProvider(context); + + Assert.False(provider.GetUseSharedCache()); + } + + [Fact] + public void AzureReposProvider_GetUseSharedCache_EnvironmentOverridesGitConfig() + { + var context = new TestCommandContext(); + context.Git.Configuration.Global[AzDevUseSharedCacheKey] = new List {"false"}; + context.Environment.Variables[AzureDevOpsConstants.EnvironmentVariables.UseSharedCache] = "true"; + var provider = new AzureReposHostProvider(context); + + Assert.True(provider.GetUseSharedCache()); + } + [Fact] public void AzureReposProvider_IsSupported_AzureHost_UnencryptedHttp_ReturnsTrue() { diff --git a/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs index ac09fed130..cd4d0241c2 100644 --- a/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -36,6 +36,7 @@ public static class EnvironmentVariables { public const string DevAadClientId = "GCM_DEV_AZREPOS_CLIENTID"; public const string DevAadAuthorityBaseUri = "GCM_DEV_AZREPOS_AUTHORITYBASEURI"; + public const string UseSharedCache = "GCM_AZREPOS_USE_MSFT_CACHE"; public const string CredentialType = "GCM_AZREPOS_CREDENTIALTYPE"; public const string ServicePrincipalId = "GCM_AZREPOS_SERVICE_PRINCIPAL"; public const string ServicePrincipalSecret = "GCM_AZREPOS_SP_SECRET"; @@ -56,6 +57,7 @@ public static class Credential { public const string DevAadClientId = "azreposDevClientId"; public const string DevAadAuthorityBaseUri = "azreposDevAuthorityBaseUri"; + public const string UseSharedCache = "azreposUseMicrosoftSharedCache"; public const string CredentialType = "azreposCredentialType"; public const string AzureAuthority = "azureAuthority"; public const string ServicePrincipal = "azreposServicePrincipal"; diff --git a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs index ff26f772fe..335baa5669 100644 --- a/src/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -406,7 +406,7 @@ private PublicClientConfig GetEntraConfig() { ClientId = GetClientId(), IsMsaPassthroughEnabled = true, - UseSharedCache = true, + UseSharedCache = GetUseSharedCache(), SupportsWindowsBroker = true, // TODO: enable once our app registration has the appropriate redirect URLs //SupportsMacBroker = true, @@ -414,6 +414,22 @@ private PublicClientConfig GetEntraConfig() }; } + internal bool GetUseSharedCache() + { + const bool defaultValue = true; // prefer using the shared Microsoft dev cache + + if (_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.UseSharedCache, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.UseSharedCache, + out string str)) + { + return str.ToBooleanyOrDefault(defaultValue); + } + + return defaultValue; + } + private string GetClientId() { // Check for developer override value