Skip to content

Add support for Workload Identity Federation #1844

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/shared/Core/Authentication/MicrosoftAuthentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(string authority, stri
/// - <c>"resource://{guid}"</c> - Use the user-assigned managed identity with resource ID <c>{guid}</c>.
/// </remarks>
Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(string managedIdentity, string resource);

/// <summary>
/// Acquire a token using a federated identity in the current environment.
/// </summary>
/// <param name="fid">An object containing configuration for the Federated Identity.</param>
/// <param name="scopes">Scopes to request.</param>
/// <returns>Authentication result including access token.</returns>
/// </remarks>
Task<IMicrosoftAuthenticationResult> GetTokenForFederatedIdentityAsync(FederatedIdentity fid, string[] scopes);
}

public class ServicePrincipalIdentity
Expand Down Expand Up @@ -99,6 +108,24 @@ public class ServicePrincipalIdentity
public bool SendX5C { get; set; }
}

public class FederatedIdentity
{
/// <summary>
/// Client ID of the assigned Managed Identity.
/// </summary>
public string ManagedIdentityClientId { get; set; }

/// <summary>
/// Tenant ID of the Managed Identity.
/// </summary>
public string TenantId { get; set; }

/// <summary>
/// Client ID of the app to request a token for.
/// </summary>
public string ClientAppId { get; set; }
}

public interface IMicrosoftAuthenticationResult
{
string AccessToken { get; }
Expand Down Expand Up @@ -312,6 +339,39 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsyn
}
}

public async Task<IMicrosoftAuthenticationResult> 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<string> 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<bool> UseDefaultAccountAsync(string userName)
{
ThrowIfUserInteractionDisabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
["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<IAzureDevOpsRestApi>();
var authorityCache = Mock.Of<IAzureDevOpsAuthorityCache>();
var userMgr = Mock.Of<IAzureReposBindingManager>();
var msAuthMock = new Mock<IMicrosoftAuthentication>();
var fidMock = new Mock<FederatedIdentity>();


msAuthMock.Setup(x => x.GetTokenForFederatedIdentityAsync(It.IsAny<FederatedIdentity>(), It.IsAny<string[]>()))
.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<FederatedIdentity>(sp => sp.ManagedIdentityClientId == federatedIdentityClientId && sp.TenantId == tenantId && sp.ClientAppId == ClientAppId),
AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once);
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential()
{
Expand Down
6 changes: 6 additions & 0 deletions src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ public async Task<ICredential> 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}...");
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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
Expand Down