From 667698614ff6fb854bccb37e0c7fc904bd439d28 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 7 Aug 2023 12:57:49 -0700 Subject: [PATCH 1/8] x509: add utils to find certs by thumbprint --- src/shared/Core/X509Utils.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/shared/Core/X509Utils.cs diff --git a/src/shared/Core/X509Utils.cs b/src/shared/Core/X509Utils.cs new file mode 100644 index 0000000000..e1558d337a --- /dev/null +++ b/src/shared/Core/X509Utils.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography.X509Certificates; + +namespace GitCredentialManager; + +public static class X509Utils +{ + public static X509Certificate2 GetCertificateByThumbprint(string thumbprint) + { + foreach (var location in new[]{StoreLocation.CurrentUser, StoreLocation.LocalMachine}) + { + using var store = new X509Store(StoreName.My, location); + store.Open(OpenFlags.ReadOnly); + + X509Certificate2Collection certs = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (certs.Count > 0) + { + return certs[0]; + } + } + + return null; + } +} From b62704458ae74ad1ca2367ce9c9f8cd160b4155f Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 3 Aug 2023 16:29:34 -0700 Subject: [PATCH 2/8] msauth: rename GetTokenAsync to GetTokenForUserAsync Rename the lone GetToken method to clarify that this is for user principals (regular user identities). This is in preparation for adding extra principal types including service principals, and managed identities. Also add some XML doc comments to the method. --- .../Authentication/MicrosoftAuthenticationTests.cs | 4 ++-- .../Core/Authentication/MicrosoftAuthentication.cs | 14 ++++++++++++-- .../AzureReposHostProviderTests.cs | 14 +++++++------- .../Microsoft.AzureRepos/AzureReposHostProvider.cs | 4 ++-- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs index ef0f50a86c..682fad12d1 100644 --- a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs +++ b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs @@ -8,7 +8,7 @@ namespace GitCredentialManager.Tests.Authentication public class MicrosoftAuthenticationTests { [Fact] - public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenAsync_NoInteraction_ThrowsException() + public async System.Threading.Tasks.Task MicrosoftAuthentication_GetTokenForUserAsync_NoInteraction_ThrowsException() { const string authority = "https://login.microsoftonline.com/common"; const string clientId = "C9E8FDA6-1D46-484C-917C-3DBD518F27C3"; @@ -24,7 +24,7 @@ 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)); } } } diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 06bd7330dc..b57267d660 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -23,7 +23,17 @@ 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); } @@ -59,7 +69,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 diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index d7fc916e14..c607f408dd 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); diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index e696e504de..84d9e7bcf4 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -197,7 +197,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 +289,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(), From 89b099e17bd15299947406f170ab7271ff19955b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 8 Aug 2023 09:08:13 -0700 Subject: [PATCH 3/8] msauth: abstract token cache init helpers Refactor the token cache helper methods to allow us to re-use the existing cache registration logic with a different ITokenCache and StorageCreationProperties. This will be useful when we later introduce a confidential client application (for service principals) that needs a different cache location, and uses the AppTokenCache, rather than the User one. --- .../Authentication/MicrosoftAuthentication.cs | 34 ++++++++++++------- .../MicrosoftAuthenticationDiagnostic.cs | 2 +- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index b57267d660..5e3ea0a921 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -422,8 +422,8 @@ 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; } @@ -432,10 +432,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()) { @@ -445,11 +446,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 @@ -477,24 +478,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; 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 Date: Mon, 7 Aug 2023 13:08:41 -0700 Subject: [PATCH 4/8] msauth: add support for service principal auth Add support for acquiring a token for a service principal. Either a client secret or certificate can be used to authenticate (the latter being preferred). --- .../Authentication/MicrosoftAuthentication.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5e3ea0a921..f3851dbc64 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; @@ -35,6 +36,43 @@ public interface IMicrosoftAuthentication /// 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); + } + + 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 @@ -210,6 +248,23 @@ public async Task GetTokenForUserAsync( } } + public async Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes) + { + IConfidentialClientApplication app = CreateConfidentialClientApplication(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; + } + } + private async Task UseDefaultAccountAsync(string userName) { ThrowIfUserInteractionDisabled(); @@ -428,6 +483,35 @@ private async Task CreatePublicClientApplicationAsync( return app; } + private IConfidentialClientApplication CreateConfidentialClientApplication(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(); + + return app; + } + #endregion #region Helpers From bfa87dba093bbb8a2ffc28288d0b3c3b10fae1c9 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 7 Aug 2023 13:01:49 -0700 Subject: [PATCH 5/8] msauth: add support for managed identity Add support for obtaining an access token using either the system-assigned and a user-assigned managed identity. --- .../MicrosoftAuthenticationTests.cs | 45 ++++++++- .../Authentication/MicrosoftAuthentication.cs | 91 ++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs index 682fad12d1..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_GetTokenForUserAsync_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"; @@ -26,5 +28,46 @@ public async System.Threading.Tasks.Task MicrosoftAuthentication_GetTokenForUser await Assert.ThrowsAsync( () => 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 f3851dbc64..3b22bb7a3a 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -13,6 +13,7 @@ using GitCredentialManager.UI; using GitCredentialManager.UI.ViewModels; using GitCredentialManager.UI.Views; +using Microsoft.Identity.Client.AppConfig; #if NETFRAMEWORK using System.Drawing; @@ -44,6 +45,25 @@ Task GetTokenForUserAsync(string authority, stri /// 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 @@ -265,6 +285,31 @@ public async Task GetTokenForServicePrincipalAsy } } + 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(); @@ -624,6 +669,50 @@ internal StorageCreationProperties CreateUserTokenCacheProps(bool useLinuxFallba 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)); + } + private static EmbeddedWebViewOptions GetEmbeddedWebViewOptions() { return new EmbeddedWebViewOptions @@ -774,7 +863,7 @@ public MsalResult(AuthenticationResult msalResult) } public string AccessToken => _msalResult.AccessToken; - public string AccountUpn => _msalResult.Account.Username; + public string AccountUpn => _msalResult.Account?.Username; } #if NETFRAMEWORK From f00c859fbadc6543452d59d4b2bd3b56d5f7d2f5 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 8 Aug 2023 09:27:29 -0700 Subject: [PATCH 6/8] msauth: add MSAL app token cache support for CCAs Add app token cache support for confidential client applications (service principals). This is a different cache than the one for user tokens that is used by public client applications (for normal users). We do not know of any other app token cache that we can share with currently, so we just use our own in the GCM data directory. --- .../Authentication/MicrosoftAuthentication.cs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 3b22bb7a3a..0dd0fefa45 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -270,7 +270,7 @@ public async Task GetTokenForUserAsync( public async Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes) { - IConfidentialClientApplication app = CreateConfidentialClientApplication(sp); + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(sp); try { @@ -528,7 +528,7 @@ private async Task CreatePublicClientApplicationAsync( return app; } - private IConfidentialClientApplication CreateConfidentialClientApplication(ServicePrincipalIdentity sp) + private async Task CreateConfidentialClientApplicationAsync(ServicePrincipalIdentity sp) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -554,6 +554,8 @@ private IConfidentialClientApplication CreateConfidentialClientApplication(Servi IConfidentialClientApplication app = appBuilder.Build(); + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); + return app; } @@ -713,6 +715,38 @@ internal static ManagedIdentityId GetManagedIdentity(string str) 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 From aafbda4a1c1680a0a93d73e5f2f44470d698a6b8 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 7 Aug 2023 13:09:35 -0700 Subject: [PATCH 7/8] azrepos: support service principals and managed IDs Allow a service principal or managed identity to be used to authenticate against Azure Repos. Required information for service principals is specified in Git config or environment variables, as is the ID for a managed identity. --- docs/configuration.md | 99 ++++++++++++++ docs/environment.md | 127 +++++++++++++++++- .../AzureDevOpsConstants.cs | 11 +- .../AzureReposHostProvider.cs | 121 ++++++++++++++++- 4 files changed, 353 insertions(+), 5 deletions(-) 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/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 84d9e7bcf4..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."); @@ -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 From eff4ea6fd47ed77c5ce0b733ea17532c5edc5ff0 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 15 Aug 2023 15:16:22 -0700 Subject: [PATCH 8/8] azrepos: add tests of MID and SP get credential Add tests of the `GetCredentialAsync` method on the `AzureReposHostProvider` using managed identity and service principal. --- .../AzureReposHostProviderTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index c607f408dd..e65674825e 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -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() {