From bd4521aebb137ff894a8ee4dd4ca7a2f6b189d5b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 24 Aug 2026 13:34:49 +0100 Subject: [PATCH 1/2] azrepos: make shared token cache configurable Azure Repos OAuth tokens currently always use the shared Microsoft developer tooling cache. While this enables authentication reuse across GCM, Visual Studio, and Azure CLI, users have no way to isolate GCM's authentication state. Allow users to select a GCM-specific token cache through Git configuration or an environment variable. Keep the shared cache as the default to preserve existing behaviour and document the interoperability and opt-out semantics. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- docs/azrepos-users-and-tokens.md | 13 ++++++-- docs/configuration.md | 22 +++++++++++++ docs/environment.md | 28 +++++++++++++++++ docs/windows-broker.md | 4 +-- .../Entra/EntraAuthentication.Caching.cs | 8 ++--- .../Entra/EntraAuthentication.PublicClient.cs | 2 +- .../AzureReposHostProviderTests.cs | 31 +++++++++++++++++++ .../AzureDevOpsConstants.cs | 2 ++ .../AzureReposHostProvider.cs | 18 ++++++++++- 9 files changed, 117 insertions(+), 11 deletions(-) diff --git a/docs/azrepos-users-and-tokens.md b/docs/azrepos-users-and-tokens.md index d5b58186bc..58654f79a3 100644 --- a/docs/azrepos-users-and-tokens.md +++ b/docs/azrepos-users-and-tokens.md @@ -38,9 +38,14 @@ Unlike PATs, Microsoft identity OAuth tokens get automatically refreshed and renewed as long as you are actively using them to perform Git operations. These tokens are also securely shared with other Microsoft developer tools -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! +including the Visual Studio IDE. 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 @@ -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..5fc8959fde 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 PowerShell +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 01f570c9d5..ed82b28381 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 PowerShell +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/docs/windows-broker.md b/docs/windows-broker.md index bfe5afcb04..679e0e5a0f 100644 --- a/docs/windows-broker.md +++ b/docs/windows-broker.md @@ -5,8 +5,8 @@ Git Credential Manager (GCM) knows how to integrate with the uses WAM to store credentials for Azure DevOps. Authentication requests are said to be "brokered" to the operating system. Currently, GCM will share authentication state with a few other Microsoft developer tools like Visual -Studio and the Azure CLI, meaning fewer authentication prompts. Enabling WAM -integration may also be required with certain +Studio, meaning fewer authentication prompts. +Enabling WAM integration may also be required with certain [Conditional Access policies][azure-conditional-access], which enterprises use to help protect their assets, including source code. diff --git a/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs b/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs index 3771ed81cd..1787510146 100644 --- a/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs +++ b/src/Core/Authentication/Entra/EntraAuthentication.Caching.cs @@ -35,7 +35,7 @@ private async Task RegisterCacheAsync(ITokenCache cache, StoragePropertiesBuilde } // We use the MSAL extension library to provide us consistent cache file access semantics (synchronisation, etc) - // as other GCM processes, and other Microsoft developer tools such as the Azure PowerShell CLI. + // as other GCM processes, and other Microsoft developer tools such as Visual Studio. MsalCacheHelper helper = null; try { @@ -86,8 +86,8 @@ private async Task RegisterCacheAsync(ITokenCache cache, StoragePropertiesBuilde /// /// Create the properties for the user token cache. This is used by public client applications only. - /// This cache is shared between GCM processes, and also other Microsoft developer tools such as the Azure - /// PowerShell CLI if the shared cache is enabled. + /// This cache is shared between GCM processes, and also other Microsoft developer tools such as + /// Visual Studio if the shared cache is enabled. /// internal StorageCreationProperties CreateUserTokenCacheProps(bool useLinuxFallback) { @@ -102,7 +102,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. + // The shared cache is used by other Microsoft developer tools such as Visual Studio. 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..760d4f1b3d 100644 --- a/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs +++ b/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs @@ -32,7 +32,7 @@ public record PublicClientConfig /// /// /// If then use the token cache shared by Microsoft - /// developer tools such as the Azure PowerShell CLI. Otherwise, use the + /// developer tools such as Visual Studio. Otherwise, use the /// Git Credential Manager cache, used only by GCM. /// public bool UseSharedCache { get; init; } 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 From fd50540a4da0578cf424a01e8cd588e14947d26b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 24 Aug 2026 14:05:09 +0100 Subject: [PATCH 2/2] diagnose: check both Entra token caches The Entra diagnostic only exercises the shared Microsoft developer tools cache. Problems with GCM's application-specific cache can therefore go undetected, while stopping at the first failure would hide the state of the other cache. Run the persistence checks for both caches and log their failures independently. Only report a warning in case of cache persistence failure. Assisted-by: GPT-5.6 Sol Signed-off-by: Matthew John Cheetham --- .../Diagnostics/EntraAuthenticationDiagnostic.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs b/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs index ef0e69948e..e4946830e0 100644 --- a/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs +++ b/src/Core/Diagnostics/EntraAuthenticationDiagnostic.cs @@ -12,13 +12,22 @@ public EntraAuthenticationDiagnostic(ICommandContext context) { } protected override async Task RunInternalAsync(IDiagnosticReporter reporter) + { + await RunDiagnosticAsync(reporter, useSharedCache: false); + await RunDiagnosticAsync(reporter, useSharedCache: true); + } + + private async Task RunDiagnosticAsync(IDiagnosticReporter reporter, bool useSharedCache) { var entraAuth = new EntraAuthentication(Context, new PublicClientConfig { - UseSharedCache = true, + UseSharedCache = useSharedCache, }); - reporter.ReportProgress("Gathering MSAL token cache data"); + reporter.ReportProgress(useSharedCache + ? "Checking shared Microsoft developer tool MSAL token cache" + : "Checking Git Credential Manager MSAL token cache"); + StorageCreationProperties cacheProps = entraAuth.CreateUserTokenCacheProps(true); reporter.ReportInfo($"CacheDirectory: {cacheProps.CacheDirectory}"); reporter.ReportInfo($"CacheFileName: {cacheProps.CacheFileName}"); @@ -47,7 +56,7 @@ protected override async Task RunInternalAsync(IDiagnosticReporter reporter) } catch (Exception ex) { - reporter.ReportError("Failed cache persistence test", ex); + reporter.ReportWarning($"Failed cache persistence test: {ex.Message}"); } } }