From e8e4e8bda550a81a57bbc194d5dbe6ba438367de Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 7 Aug 2023 13:01:49 -0700 Subject: [PATCH 1/3] 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 682fad12d..0e1a70659 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 54ef08f00..7ff198a92 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 0f1f308a4b4888334d6eaa7cb209e2d64f02e237 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 8 Aug 2023 09:27:29 -0700 Subject: [PATCH 2/3] 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 7ff198a92..da0f9add8 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 340a7e22246480e6f3a43412b248d4277a83d4a5 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 7 Aug 2023 13:09:35 -0700 Subject: [PATCH 3/3] 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 268e35c40..76d1470f3 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 specified 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 f3d8a618e..cccd58dc1 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 specified 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 2bd239305..c46f08c33 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 84d9e7bcf..f46dded40 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 < 2 || 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 (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