diff --git a/docs/configuration.md b/docs/configuration.md index 268e35c40e..88a23c1039 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -793,6 +793,95 @@ git config --global credential.azreposCredentialType oauth --- +### credential.azreposManagedIdentity + +Use a [Managed Identity][managed-identity] to authenticate with Azure Repos. + +The value `system` will tell GCM to use the system-assigned Managed Identity. + +To specify a user-assigned Managed Identity, use the format `id://{clientId}` +where `{clientId}` is the client ID of the Managed Identity. Alternatively any +GUID-like value will also be interpreted as a user-assigned Managed Identity +client ID. + +To specify a Managed Identity associated with an Azure resource, you can use the +format `resource://{resourceId}` where `{resourceId}` is the ID of the resource. + +For more information about managed identities, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +```shell +git config --global credential.azreposManagedIdentity "id://11111111-1111-1111-1111-111111111111" +``` + +**Also see: [GCM_AZREPOS_MANAGEDIDENTITY][gcm-azrepos-credentialmanagedidentity]** + +--- + +### credential.azreposServicePrincipal + +Specify the client and tenant IDs of a [service principal][service-principal] +to use when performing Microsoft authentication for Azure Repos. + +The value of this setting should be in the format: `{tenantId}/{clientId}`. + +You must also set at least one authentication mechanism if you set this value: + +- [credential.azreposServicePrincipalSecret][credential-azrepos-sp-secret] +- [credential.azreposServicePrincipalCertificateThumbprint][credential-azrepos-sp-cert-thumbprint] + +For more information about service principals, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Example + +```shell +git config --global credential.azreposServicePrincipal "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +**Also see: [GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-service-principal]** + +--- + +### credential.azreposServicePrincipalSecret + +Specifies the client secret for the [service principal][service-principal] when +performing Microsoft authentication for Azure Repos with +[credential.azreposServicePrincipalSecret][credential-azrepos-sp] set. + +#### Example + +```shell +git config --global credential.azreposServicePrincipalSecret "da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +**Also see: [GCM_AZREPOS_SP_SECRET][gcm-azrepos-sp-secret]** + +--- + +### credential.azreposServicePrincipalCertificateThumbprint + +Specifies the thumbprint of a certificate to use when authenticating as a +[service principal][service-principal] for Azure Repos when +[GCM_AZREPOS_SERVICE_PRINCIPAL][credential-azrepos-sp] is set. + +#### Example + +```shell +git config --global credential.azreposServicePrincipalCertificateThumbprint "9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +**Also see: [GCM_AZREPOS_SP_CERT_THUMBPRINT][gcm-azrepos-sp-cert-thumbprint]** + +--- + ### trace2.normalTarget Turns on Trace2 Normal Format tracing - see [Git's Trace2 Normal Format @@ -878,6 +967,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-credentialmanagedidentity]: environment.md#GCM_AZREPOS_MANAGEDIDENTITY [gcm-bitbucket-always-refresh-credentials]: environment.md#GCM_BITBUCKET_ALWAYS_REFRESH_CREDENTIALS [gcm-bitbucket-authmodes]: environment.md#GCM_BITBUCKET_AUTHMODES [gcm-credential-cache-options]: environment.md#GCM_CREDENTIAL_CACHE_OPTIONS @@ -905,6 +995,7 @@ Defaults to disabled. [http-proxy]: netconfig.md#http-proxy [autodetect]: autodetect.md [libsecret]: https://wiki.gnome.org/Projects/Libsecret +[managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview [provider-migrate]: migration.md#gcm_authority [cache-options]: https://git-scm.com/docs/git-credential-cache#_options [pass]: https://www.passwordstore.org/ @@ -915,3 +1006,11 @@ Defaults to disabled. [trace2-performance-docs]: https://git-scm.com/docs/api-trace2#_the_performance_format_target [trace2-performance-env]: environment.md#GIT_TRACE2_PERF [wam]: windows-broker.md +[service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals +[azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[credential-azrepos-sp]: #credentialazreposserviceprincipal +[credential-azrepos-sp-secret]: #credentialazreposserviceprincipalsecret +[credential-azrepos-sp-cert-thumbprint]: #credentialazreposserviceprincipalcertificatethumbprint +[gcm-azrepos-service-principal]: environment.md#GCM_AZREPOS_SERVICE_PRINCIPAL +[gcm-azrepos-sp-secret]: environment.md#GCM_AZREPOS_SP_SECRET +[gcm-azrepos-sp-cert-thumbprint]: environment.md#GCM_AZREPOS_SP_CERT_THUMBPRINT diff --git a/docs/environment.md b/docs/environment.md index f3d8a618ef..6666044223 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -894,6 +894,121 @@ export GCM_AZREPOS_CREDENTIALTYPE="oauth" --- +### GCM_AZREPOS_MANAGEDIDENTITY + +Use a [Managed Identity][managed-identity] to authenticate with Azure Repos. + +The value `system` will tell GCM to use the system-assigned Managed Identity. + +To specify a user-assigned Managed Identity, use the format `id://{clientId}` +where `{clientId}` is the client ID of the Managed Identity. Alternatively any +GUID-like value will also be interpreted as a user-assigned Managed Identity +client ID. + +To specify a Managed Identity associated with an Azure resource, you can use the +format `resource://{resourceId}` where `{resourceId}` is the ID of the resource. + +For more information about managed identities, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Windows + +```batch +SET GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" +``` + +**Also see: [credential.azreposManagedIdentity][credential-azrepos-managedidentity]** + +--- + +### GCM_AZREPOS_SERVICE_PRINCIPAL + +Specify the client and tenant IDs of a [service principal][service-principal] +to use when performing Microsoft authentication for Azure Repos. + +The value of this setting should be in the format: `{tenantId}/{clientId}`. + +You must also set at least one authentication mechanism if you set this value: + +- [GCM_AZREPOS_SP_SECRET][gcm-azrepos-sp-secret] +- [GCM_AZREPOS_SP_CERT_THUMBPRINT][gcm-azrepos-sp-cert-thumbprint] + +For more information about service principals, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Windows + +```batch +SET GCM_AZREPOS_SERVICE_PRINCIPAL="11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SERVICE_PRINCIPAL="11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +**Also see: [credential.azreposServicePrincipal][credential-azrepos-sp]** + +--- + +### GCM_AZREPOS_SP_SECRET + +Specifies the client secret for the [service principal][service-principal] when +performing Microsoft authentication for Azure Repos with +[GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-sp] set. + +#### Windows + +```batch +SET GCM_AZREPOS_SP_SECRET="da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SP_SECRET="da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +**Also see: [credential.azreposServicePrincipalSecret][credential-azrepos-sp-secret]** + +--- + +### GCM_AZREPOS_SP_CERT_THUMBPRINT + +Specifies the thumbprint of a certificate to use when authenticating as a +[service principal][service-principal] for Azure Repos when +[GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-sp] is set. + +#### Windows + +```batch +SET GCM_AZREPOS_SP_CERT_THUMBPRINT="9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SP_CERT_THUMBPRINT="9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +**Also see: [credential.azreposServicePrincipalCertificateThumbprint][credential-azrepos-sp-cert-thumbprint]** + +--- + ### GIT_TRACE2 Turns on Trace2 Normal Format tracing - see [Git's Trace2 Normal Format @@ -985,7 +1100,8 @@ Defaults to disabled. [credential-allowwindowsauth]: environment.md#credentialallowWindowsAuth [credential-authority]: configuration.md#credentialauthority-deprecated [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout -[credential-azrepos-credential-type]: configuration.md#azreposcredentialtype +[credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype +[credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity [credential-bitbucketauthmodes]: configuration.md#credentialbitbucketAuthModes [credential-cacheoptions]: configuration.md#credentialcacheoptions [credential-credentialstore]: configuration.md#credentialcredentialstore @@ -1022,6 +1138,7 @@ Defaults to disabled. [github-emu]: https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-enterprise-managed-users-for-iam/about-enterprise-managed-users [network-http-proxy]: netconfig.md#http-proxy [libsecret]: https://wiki.gnome.org/Projects/Libsecret +[managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview [migration-guide]: migration.md#gcm_authority [passwordstore]: https://www.passwordstore.org/ [trace2-normal-docs]: https://git-scm.com/docs/api-trace2#_the_normal_format_target @@ -1031,3 +1148,11 @@ Defaults to disabled. [trace2-performance-docs]: https://git-scm.com/docs/api-trace2#_the_performance_format_target [trace2-performance-config]: configuration.md#trace2perfTarget [windows-broker]: windows-broker.md +[service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals +[azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[gcm-azrepos-sp]: #gcm_azrepos_service_principal +[gcm-azrepos-sp-secret]: #gcm_azrepos_sp_secret +[gcm-azrepos-sp-cert-thumbprint]: #gcm_azrepos_sp_cert_thumbprint +[credential-azrepos-sp]: configuration.md#credentialazreposserviceprincipal +[credential-azrepos-sp-secret]: configuration.md#credentialazreposserviceprincipalsecret +[credential-azrepos-sp-cert-thumbprint]: configuration.md#credentialazreposserviceprincipalcertificatethumbprint diff --git a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs index ef0f50a86c..0e1a70659e 100644 --- a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs +++ b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs @@ -1,6 +1,8 @@ using System; +using System.Threading.Tasks; using GitCredentialManager.Authentication; using GitCredentialManager.Tests.Objects; +using Microsoft.Identity.Client.AppConfig; using Xunit; namespace GitCredentialManager.Tests.Authentication @@ -8,7 +10,7 @@ namespace GitCredentialManager.Tests.Authentication public class MicrosoftAuthenticationTests { [Fact] - public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenAsync_NoInteraction_ThrowsException() + public async Task MicrosoftAuthentication_GetTokenForUserAsync_NoInteraction_ThrowsException() { const string authority = "https://login.microsoftonline.com/common"; const string clientId = "C9E8FDA6-1D46-484C-917C-3DBD518F27C3"; @@ -24,7 +26,48 @@ public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenA var msAuth = new MicrosoftAuthentication(context); await Assert.ThrowsAsync( - () => msAuth.GetTokenAsync(authority, clientId, redirectUri, scopes, userName, false)); + () => msAuth.GetTokenForUserAsync(authority, clientId, redirectUri, scopes, userName, false)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("system")] + [InlineData("SYSTEM")] + [InlineData("sYsTeM")] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("id://00000000-0000-0000-0000-000000000000")] + [InlineData("ID://00000000-0000-0000-0000-000000000000")] + [InlineData("Id://00000000-0000-0000-0000-000000000000")] + public void MicrosoftAuthentication_GetManagedIdentity_ValidSystemId_ReturnsSystemId(string str) + { + ManagedIdentityId actual = MicrosoftAuthentication.GetManagedIdentity(str); + Assert.Equal(ManagedIdentityId.SystemAssigned, actual); + } + + [Theory] + [InlineData("8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("id://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("ID://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("Id://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("resource://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("RESOURCE://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("rEsOuRcE://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("resource://00000000-0000-0000-0000-000000000000")] + public void MicrosoftAuthentication_GetManagedIdentity_ValidUserIdByClientId_ReturnsUserId(string str) + { + ManagedIdentityId actual = MicrosoftAuthentication.GetManagedIdentity(str); + Assert.NotNull(actual); + Assert.NotEqual(ManagedIdentityId.SystemAssigned, actual); + } + + [Theory] + [InlineData("unknown://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("this is a string")] + public void MicrosoftAuthentication_GetManagedIdentity_Invalid_ThrowsArgumentException(string str) + { + Assert.Throws(() => MicrosoftAuthentication.GetManagedIdentity(str)); } } } diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 06bd7330dc..0dd0fefa45 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using GitCredentialManager.Interop.Windows.Native; using Microsoft.Identity.Client; @@ -12,6 +13,7 @@ using GitCredentialManager.UI; using GitCredentialManager.UI.ViewModels; using GitCredentialManager.UI.Views; +using Microsoft.Identity.Client.AppConfig; #if NETFRAMEWORK using System.Drawing; @@ -23,8 +25,74 @@ namespace GitCredentialManager.Authentication { public interface IMicrosoftAuthentication { - Task GetTokenAsync(string authority, string clientId, Uri redirectUri, + /// + /// Acquire an access token for a user principal. + /// + /// Azure authority. + /// Client ID. + /// Redirect URI for the client. + /// Set of scopes to request. + /// Optional user name for an existing account. + /// Use MSA-Passthrough behavior when authenticating. + /// Authentication result. + Task GetTokenForUserAsync(string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt = false); + + /// + /// Acquire an access token for the given service principal with the specified scopes. + /// + /// Service principal identity. + /// Scopes to request. + /// Authentication result. + Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes); + + /// + /// Acquire a token using the managed identity in the current environment. + /// + /// Managed identity to use. + /// Resource to obtain an access token for. + /// Authentication result including access token. + /// + /// There are several formats for the parameter: + /// + /// - "system" - Use the system-assigned managed identity. + /// + /// - "{guid}" - Use the user-assigned managed identity with client ID {guid}. + /// + /// - "id://{guid}" - Use the user-assigned managed identity with client ID {guid}. + /// + /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. + /// + Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + } + + public class ServicePrincipalIdentity + { + /// + /// Client ID of the service principal. + /// + public string Id { get; set; } + + /// + /// Tenant ID of the service principal. + /// + public string TenantId { get; set; } + + /// + /// Certificate used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public X509Certificate2 Certificate { get; set; } + + /// + /// Secret used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public string ClientSecret { get; set; } } public interface IMicrosoftAuthenticationResult @@ -59,7 +127,7 @@ public MicrosoftAuthentication(ICommandContext context) #region IMicrosoftAuthentication - public async Task GetTokenAsync( + public async Task GetTokenForUserAsync( string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt) { // Check if we can and should use OS broker authentication @@ -200,6 +268,48 @@ public async Task GetTokenAsync( } } + public async Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes) + { + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(sp); + + try + { + AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync(); + return new MsalResult(result); + } + catch (Exception ex) + { + Context.Trace.WriteLine($"Failed to acquire token for service principal '{sp.TenantId}/{sp.TenantId}'."); + Context.Trace.WriteException(ex); + throw; + } + } + + public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + ManagedIdentityId mid = GetManagedIdentity(managedIdentity); + + IManagedIdentityApplication app = ManagedIdentityApplicationBuilder.Create(mid) + .WithHttpClientFactory(httpFactoryAdaptor) + .Build(); + + try + { + AuthenticationResult result = await app.AcquireTokenForManagedIdentity(resource).ExecuteAsync(); + return new MsalResult(result); + } + catch (Exception ex) + { + Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned + ? "Failed to acquire token for system managed identity." + : $"Failed to acquire token for user managed identity '{managedIdentity:D}'."); + Context.Trace.WriteException(ex); + throw; + } + } + private async Task UseDefaultAccountAsync(string userName) { ThrowIfUserInteractionDisabled(); @@ -412,8 +522,39 @@ private async Task CreatePublicClientApplicationAsync( IPublicClientApplication app = appBuilder.Build(); - // Register the application token cache - await RegisterTokenCacheAsync(app, Context.Trace2); + // Register the user token cache + await RegisterTokenCacheAsync(app.UserTokenCache, CreateUserTokenCacheProps, Context.Trace2); + + return app; + } + + private async Task CreateConfidentialClientApplicationAsync(ServicePrincipalIdentity sp) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + Context.Trace.WriteLine($"Creating confidential client application for {sp.TenantId}/{sp.Id}..."); + var appBuilder = ConfidentialClientApplicationBuilder.Create(sp.Id) + .WithTenantId(sp.TenantId) + .WithHttpClientFactory(httpFactoryAdaptor); + + if (sp.Certificate is not null) + { + Context.Trace.WriteLineSecrets("Using certificate with thumbprint: '{0}'", new object[] { sp.Certificate.Thumbprint }); + appBuilder = appBuilder.WithCertificate(sp.Certificate); + } + else if (!string.IsNullOrWhiteSpace(sp.ClientSecret)) + { + Context.Trace.WriteLineSecrets("Using client secret: '{0}'", new object[] { sp.ClientSecret }); + appBuilder = appBuilder.WithClientSecret(sp.ClientSecret); + } + else + { + throw new InvalidOperationException("Service principal identity does not contain a certificate or client secret."); + } + + IConfidentialClientApplication app = appBuilder.Build(); + + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); return app; } @@ -422,10 +563,11 @@ private async Task CreatePublicClientApplicationAsync( #region Helpers - private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 trace2) + private delegate StorageCreationProperties StoragePropertiesBuilder(bool useLinuxFallback); + + private async Task RegisterTokenCacheAsync(ITokenCache cache, StoragePropertiesBuilder propsBuilder, ITrace2 trace2) { - Context.Trace.WriteLine( - "Configuring Microsoft Authentication token cache to instance shared with Microsoft developer tools..."); + Context.Trace.WriteLine("Configuring MSAL token cache..."); if (!PlatformUtils.IsWindows() && !PlatformUtils.IsPosix()) { @@ -435,11 +577,11 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 } // We use the MSAL extension library to provide us consistent cache file access semantics (synchronisation, etc) - // as other Microsoft developer tools such as the Azure PowerShell CLI. + // as other GCM processes, and other Microsoft developer tools such as the Azure PowerShell CLI. MsalCacheHelper helper = null; try { - var storageProps = CreateTokenCacheProps(useLinuxFallback: false); + StorageCreationProperties storageProps = propsBuilder(useLinuxFallback: false); helper = await MsalCacheHelper.CreateAsync(storageProps); // Test that cache access is working correctly @@ -467,24 +609,31 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 // On Linux the SecretService/keyring might not be available so we must fall-back to a plaintext file. Context.Streams.Error.WriteLine("warning: using plain-text fallback token cache"); Context.Trace.WriteLine("Using fall-back plaintext token cache on Linux."); - var storageProps = CreateTokenCacheProps(useLinuxFallback: true); + StorageCreationProperties storageProps = propsBuilder(useLinuxFallback: true); helper = await MsalCacheHelper.CreateAsync(storageProps); } } if (helper is null) { - Context.Streams.Error.WriteLine("error: failed to set up Microsoft Authentication token cache!"); - Context.Trace.WriteLine("Failed to integrate with shared token cache!"); + Context.Streams.Error.WriteLine("error: failed to set up token cache!"); + Context.Trace.WriteLine("Failed to integrate with token cache!"); } else { - helper.RegisterCache(app.UserTokenCache); - Context.Trace.WriteLine("Microsoft developer tools token cache configured."); + helper.RegisterCache(cache); + Context.Trace.WriteLine("Token cache configured."); } } - internal StorageCreationProperties CreateTokenCacheProps(bool useLinuxFallback) + /// + /// 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. + /// + /// + /// + internal StorageCreationProperties CreateUserTokenCacheProps(bool useLinuxFallback) { const string cacheFileName = "msal.cache"; string cacheDirectory; @@ -522,6 +671,82 @@ internal StorageCreationProperties CreateTokenCacheProps(bool useLinuxFallback) return builder.Build(); } + internal static ManagedIdentityId GetManagedIdentity(string str) + { + // An empty string or "system" means system-assigned managed identity + if (string.IsNullOrWhiteSpace(str) || str.Equals("system", StringComparison.OrdinalIgnoreCase)) + { + return ManagedIdentityId.SystemAssigned; + } + + // + // A GUID-looking value means a user-assigned managed identity specified by the client ID. + // If the "{value}" is the empty GUID then we use the system-assigned MI. + // + if (Guid.TryParse(str, out Guid guid)) + { + return guid == Guid.Empty + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(str); + } + + // + // A value of the form "id://{value}" means a user-assigned managed identity specified by the client ID. + // If the "{value}" is the empty GUID then we use the system-assigned MI. + // + // If the value is "resource://{value}" then it is a user-assigned managed identity specified + // by the resource ID. + // + if (Uri.TryCreate(str, UriKind.Absolute, out Uri uri)) + { + if (StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "id")) + { + return Guid.TryParse(uri.Host, out Guid g) && g == Guid.Empty + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(uri.Host); + } + + if (StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "resource")) + { + return ManagedIdentityId.WithUserAssignedResourceId(uri.Host); + } + } + + throw new ArgumentException("Invalid managed identity value.", nameof(str)); + } + + /// + /// Create the properties for the application token cache. This is used by confidential client applications only + /// and is not shared between applications other than GCM. + /// + internal StorageCreationProperties CreateAppTokenCacheProps(bool useLinuxFallback) + { + const string cacheFileName = "app.cache"; + + // The confidential client MSAL cache is located at "%UserProfile%\.gcm\msal\app.cache" on Windows + // and at "~/.gcm/msal/app.cache" on UNIX. + string cacheDirectory = Path.Combine(Context.FileSystem.UserDataDirectoryPath, "msal"); + + // The keychain is used on macOS with the following service & account names + var builder = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory) + .WithMacKeyChain("GitCredentialManager.MSAL", "AppCache"); + + if (useLinuxFallback) + { + builder.WithLinuxUnprotectedFile(); + } + else + { + // The SecretService/keyring is used on Linux with the following collection name and attributes + builder.WithLinuxKeyring(cacheFileName, + "default", "AppCache", + new KeyValuePair("MsalClientID", "GitCredentialManager.MSAL"), + new KeyValuePair("GitCredentialManager.MSAL", "1.0.0.0")); + } + + return builder.Build(); + } + private static EmbeddedWebViewOptions GetEmbeddedWebViewOptions() { return new EmbeddedWebViewOptions @@ -672,7 +897,7 @@ public MsalResult(AuthenticationResult msalResult) } public string AccessToken => _msalResult.AccessToken; - public string AccountUpn => _msalResult.Account.Username; + public string AccountUpn => _msalResult.Account?.Username; } #if NETFRAMEWORK diff --git a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs index 05ed9200ce..e4dba08224 100644 --- a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs +++ b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs @@ -20,7 +20,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList 0) + { + return certs[0]; + } + } + + return null; + } +} diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index d7fc916e14..e65674825e 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -170,7 +170,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -219,7 +219,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -268,7 +268,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -315,7 +315,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -363,7 +363,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -413,7 +413,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -462,7 +462,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge .ReturnsAsync(personalAccessToken); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -511,6 +511,102 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu Assert.Equal(personalAccessToken, credential.Password); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsManagedIdCredential() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "MANAGED-IDENTITY-TOKEN"; + const string managedIdentity = "MANAGED-IDENTITY"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.ManagedIdentity] = managedIdentity + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenForManagedIdentityAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + ICredential credential = await provider.GetCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(managedIdentity, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenForManagedIdentityAsync(managedIdentity, + AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "SP-TOKEN"; + const string tenantId = "78B1822F-107D-40A3-A29C-AB68D8066074"; + const string clientId = "49B4DC1A-58A8-4EEE-A81B-616A40D0BA64"; + const string servicePrincipal = $"{tenantId}/{clientId}"; + const string servicePrincipalSecret = "CLIENT-SECRET"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalId] = servicePrincipal, + [AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalSecret] = servicePrincipalSecret + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => + x.GetTokenForServicePrincipalAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + ICredential credential = await provider.GetCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify(x => x.GetTokenForServicePrincipalAsync( + It.Is(sp => sp.TenantId == tenantId && sp.Id == clientId), + It.Is(scopes => scopes.Length == 1 && scopes[0] == AzureDevOpsConstants.AzureDevOpsDefaultScopes[0])), + Times.Once); + } + [Fact] public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetTrue_DoesNothing() { diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index 2bd2393056..c46f08c33b 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -8,7 +8,8 @@ internal static class AzureDevOpsConstants public const string AadAuthorityBaseUrl = "https://login.microsoftonline.com"; // Azure DevOps's app ID + default scopes - public static readonly string[] AzureDevOpsDefaultScopes = {"499b84ac-1321-427f-aa17-267ca6975798/.default"}; + public const string AzureDevOpsResourceId = "499b84ac-1321-427f-aa17-267ca6975798"; + public static readonly string[] AzureDevOpsDefaultScopes = {$"{AzureDevOpsResourceId}/.default"}; // Visual Studio's client ID // We share this to be able to consume existing access tokens from the VS caches @@ -40,6 +41,10 @@ public static class EnvironmentVariables public const string DevAadRedirectUri = "GCM_DEV_AZREPOS_REDIRECTURI"; public const string DevAadAuthorityBaseUri = "GCM_DEV_AZREPOS_AUTHORITYBASEURI"; public const string CredentialType = "GCM_AZREPOS_CREDENTIALTYPE"; + public const string ServicePrincipalId = "GCM_AZREPOS_SERVICE_PRINCIPAL"; + public const string ServicePrincipalSecret = "GCM_AZREPOS_SP_SECRET"; + public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; + public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; } public static class GitConfiguration @@ -51,6 +56,10 @@ public static class Credential public const string DevAadAuthorityBaseUri = "azreposDevAuthorityBaseUri"; public const string CredentialType = "azreposCredentialType"; public const string AzureAuthority = "azureAuthority"; + public const string ServicePrincipal = "azreposServicePrincipal"; + public const string ServicePrincipalSecret = "azreposServicePrincipalSecret"; + public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; + public const string ManagedIdentity = "azreposManagedIdentity"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index e696e504de..941b2bd531 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Linq; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using System.Threading.Tasks; using GitCredentialManager; @@ -75,6 +76,20 @@ public bool IsSupported(HttpResponseMessage response) public async Task GetCredentialAsync(InputArguments input) { + if (UseManagedIdentity(out string mid)) + { + _context.Trace.WriteLine($"Getting Azure Access Token for managed identity {mid}..."); + var azureResult = await _msAuth.GetTokenForManagedIdentityAsync(mid, AzureDevOpsConstants.AzureDevOpsResourceId); + return new GitCredential(mid, azureResult.AccessToken); + } + + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) + { + _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); + var azureResult = await _msAuth.GetTokenForServicePrincipalAsync(sp, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GitCredential(sp.Id, azureResult.AccessToken); + } + if (UsePersonalAccessTokens()) { Uri remoteUri = input.GetRemoteUri(); @@ -113,7 +128,15 @@ public Task StoreCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); - if (UsePersonalAccessTokens()) + if (UseManagedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to store for managed identity authentication."); + } + else if (UseServicePrincipal(out _)) + { + _context.Trace.WriteLine("Nothing to store for service principal authentication."); + } + else if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); @@ -140,13 +163,22 @@ public Task EraseCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); - if (UsePersonalAccessTokens()) + if (UseManagedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); + } + else if (UseServicePrincipal(out _)) + { + _context.Trace.WriteLine("Nothing to erase for service principal authentication."); + } + else if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); string account = GetAccountNameForCredentialQuery(input); // Try to locate an existing credential - _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={account}..."); + _context.Trace.WriteLine( + $"Erasing stored credential in store with service={service} account={account}..."); if (_context.CredentialStore.Remove(service, account)) { _context.Trace.WriteLine("Credential was successfully erased."); @@ -197,7 +229,7 @@ private async Task GeneratePersonalAccessTokenAsync(InputArguments // Get an AAD access token for the Azure DevOps SPS _context.Trace.WriteLine("Getting Azure AD access token..."); - IMicrosoftAuthenticationResult result = await _msAuth.GetTokenAsync( + IMicrosoftAuthenticationResult result = await _msAuth.GetTokenForUserAsync( authAuthority, GetClientId(), GetRedirectUri(), @@ -289,7 +321,7 @@ private async Task GetAzureAccessTokenAsync(Inpu // Get an AAD access token for the Azure DevOps SPS _context.Trace.WriteLine("Getting Azure AD access token..."); - IMicrosoftAuthenticationResult result = await _msAuth.GetTokenAsync( + IMicrosoftAuthenticationResult result = await _msAuth.GetTokenForUserAsync( authAuthority, GetClientId(), GetRedirectUri(), @@ -461,6 +493,89 @@ private bool UsePersonalAccessTokens() return defaultValue; } + private bool UseServicePrincipal(out ServicePrincipalIdentity sp) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipal, + out string spStr) || string.IsNullOrWhiteSpace(spStr)) + { + sp = null; + return false; + } + + string[] split = spStr.Split(new[] { '/' }, count: 2); + + if (split.Length < 1 || string.IsNullOrWhiteSpace(split[0])) + { + _context.Streams.Error.WriteLine("error: unable to use configured service principal - missing tenant ID in configuration"); + sp = null; + return false; + } + + if (split.Length < 2 || string.IsNullOrWhiteSpace(split[1])) + { + _context.Streams.Error.WriteLine("error: unable to use configured service principal - missing client ID in configuration"); + sp = null; + return false; + } + + string tenantId = split[0]; + string clientId = split[1]; + + sp = new ServicePrincipalIdentity + { + Id = clientId, + TenantId = tenantId, + }; + + bool hasClientSecret = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalSecret, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipalSecret, + out string clientSecret); + + bool hasCertThumbprint = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalCertificateThumbprint, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipalCertificateThumbprint, + out string certThumbprint); + + if (hasCertThumbprint && hasClientSecret) + { + _context.Streams.Error.WriteLine("warning: both service principal client secret and certificate thumbprint are configured - using certificate"); + } + + if (hasCertThumbprint) + { + X509Certificate2 cert = X509Utils.GetCertificateByThumbprint(certThumbprint); + if (cert is null) + { + _context.Streams.Error.WriteLine($"error: unable to find certificate with thumbprint '{certThumbprint}' for service principal"); + return false; + } + + sp.Certificate = cert; + } + else if (hasClientSecret) + { + sp.ClientSecret = clientSecret; + } + + return true; + } + + private bool UseManagedIdentity(out string mid) + { + return _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ManagedIdentity, + KnownGitCfg.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ManagedIdentity, + out mid) && + !string.IsNullOrWhiteSpace(mid); + } + #endregion #region IConfigurationComponent