diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 12bccf5fe..ff4bc9cd4 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -63,6 +63,15 @@ Task GetTokenForUserAsync(string authority, stri /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. /// Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + + /// + /// Acquire a token using a federated identity in the current environment. + /// + /// An object containing configuration for the Federated Identity. + /// Scopes to request. + /// Authentication result including access token. + /// + Task GetTokenForFederatedIdentityAsync(FederatedIdentity fid, string[] scopes); } public class ServicePrincipalIdentity @@ -99,6 +108,24 @@ public class ServicePrincipalIdentity public bool SendX5C { get; set; } } + public class FederatedIdentity + { + /// + /// Client ID of the assigned Managed Identity. + /// + public string ManagedIdentityClientId { get; set; } + + /// + /// Tenant ID of the Managed Identity. + /// + public string TenantId { get; set; } + + /// + /// Client ID of the app to request a token for. + /// + public string ClientAppId { get; set; } + } + public interface IMicrosoftAuthenticationResult { string AccessToken { get; } @@ -312,6 +339,39 @@ public async Task GetTokenForManagedIdentityAsyn } } + public async Task GetTokenForFederatedIdentityAsync(FederatedIdentity fid, string[] scopes) + { + string audience = "api://AzureADTokenExchange"; + Uri authorityUri = new($"https://login.microsoftonline.com/{fid.TenantId}"); + + // Request a token for the current Managed Identity + async Task miAssertionProvider(AssertionRequestOptions _) + { + var miApplication = ManagedIdentityApplicationBuilder + .Create(ManagedIdentityId.WithUserAssignedClientId(fid.ManagedIdentityClientId)) + .Build(); + + var miResult = await miApplication.AcquireTokenForManagedIdentity(audience) + .ExecuteAsync() + .ConfigureAwait(false); + + return miResult.AccessToken; + } + + // Exchange the token for an ADO access token + IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(fid.ClientAppId) + .WithAuthority(authorityUri, false) + .WithClientAssertion(miAssertionProvider) + .WithCacheOptions(CacheOptions.EnableSharedCacheOptions) + .Build(); + + AuthenticationResult result = await app.AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + return new MsalResult(result); + } + private async Task UseDefaultAccountAsync(string userName) { ThrowIfUserInteractionDisabled(); diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index e379aaa3b..dd5353697 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -595,6 +595,57 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_FederatedIdentity_ReturnsFederatedIdCredential() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string federatedIdentityClientId = "00000000-0000-0000-0000-000000000000"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string ClientAppId = "00000000-0000-0000-0000-000000000000"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.FederatedIdentity] = federatedIdentityClientId, + [AzureDevOpsConstants.EnvironmentVariables.FederatedIdentityTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.FederatedIdentityClientAppId] = ClientAppId + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + var fidMock = new Mock(); + + + msAuthMock.Setup(x => x.GetTokenForFederatedIdentityAsync(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(federatedIdentityClientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenForFederatedIdentityAsync(It.Is(sp => sp.ManagedIdentityClientId == federatedIdentityClientId && sp.TenantId == tenantId && sp.ClientAppId == ClientAppId), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + [Fact] public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() { diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index a282d4eff..7de7216de 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -46,6 +46,9 @@ public static class EnvironmentVariables public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; public const string ServicePrincipalCertificateSendX5C = "GCM_AZREPOS_SP_CERT_SEND_X5C"; public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; + public const string FederatedIdentity = "GCM_AZREPOS_FEDERATEDIDENTITY"; + public const string FederatedIdentityTenantId = "GCM_AZREPOS_FEDERATEDIDENTITY_TENANTID"; + public const string FederatedIdentityClientAppId = "GCM_AZREPOS_FEDERATEDIDENTITY_CLIENTAPPID"; } public static class GitConfiguration @@ -62,6 +65,9 @@ public static class Credential public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; public const string ServicePrincipalCertificateSendX5C = "azreposServicePrincipalCertificateSendX5C"; public const string ManagedIdentity = "azreposManagedIdentity"; + public const string FederatedIdentity = "azreposFederatedIdentity"; + public const string FederatedIdentityTenantId = "azreposFederatedIdentityTenantId"; + public const string FederatedIdentityClientAppId = "azreposFederatedIdentityClientAppId"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 525704886..1822ec3a2 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -83,6 +83,13 @@ public async Task GetCredentialAsync(InputArguments input) return new GitCredential(mid, azureResult.AccessToken); } + if (UseFederatedIdentity(out FederatedIdentity fid)) + { + _context.Trace.WriteLine($"Getting Azure Access Token for Federated identity {fid.ManagedIdentityClientId}..."); + var azureResult = await _msAuth.GetTokenForFederatedIdentityAsync(fid, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GitCredential(fid.ManagedIdentityClientId, azureResult.AccessToken); + } + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); @@ -132,6 +139,10 @@ public Task StoreCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to store for managed identity authentication."); } + else if (UseFederatedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to store for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to store for service principal authentication."); @@ -167,6 +178,10 @@ public Task EraseCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); } + else if (UseFederatedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to erase for federated principal authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to erase for service principal authentication."); @@ -583,6 +598,47 @@ private bool UseManagedIdentity(out string mid) !string.IsNullOrWhiteSpace(mid); } + private bool UseFederatedIdentity(out FederatedIdentity fid) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.FederatedIdentity, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.FederatedIdentity, + out string midStr) || string.IsNullOrWhiteSpace(midStr)) + { + fid = null; + return false; + } + + bool fedClientAppId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.FederatedIdentityClientAppId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.FederatedIdentityClientAppId, + out string clientId); + + bool fedTenantId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.FederatedIdentityTenantId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.FederatedIdentityTenantId, + out string tenantId); + + if (!fedClientAppId || !fedTenantId) + { + _context.Streams.Error.WriteLine("error: both federated identity client app ID and tenant ID are required"); + fid = null; + return false; + } + + fid = new FederatedIdentity() + { + ManagedIdentityClientId = midStr, + ClientAppId = clientId, + TenantId = tenantId + }; + + return true; + } + #endregion #region IConfigurationComponent