Skip to content

Commit a25aa2d

Browse files
committed
wif: Add implementation of Workload Identity Federation for AzRepos
Add support for Workload Identity Federation (WIF) for Azure Repos. This enables users to authenticate to Azure Repos using federated tokens from Managed Identities, GitHub Actions, or generic identity providers. We support three scenarios: 1. Generic When you have a pre-obtained client assertion token from any external identity provider. You provide the assertion directly and GCM exchanges it for an access token. 2. Entra ID Managed Identities When your workload runs on an Azure resource that has a Managed Identity assigned. GCM will first request a token from the Managed Identity for the configured audience, then exchange that token for an Azure DevOps access token. 3. GitHub Actions When your workload runs in a GitHub Actions workflow. GCM will automatically obtain an OIDC token from the GitHub Actions runtime and exchange it for an Azure DevOps access token. Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
1 parent c6e4b33 commit a25aa2d

6 files changed

Lines changed: 617 additions & 2 deletions

File tree

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Identity.Client;
1010
using Microsoft.Identity.Client.Extensions.Msal;
1111
using System.Text;
12+
using System.Text.Json;
1213
using System.Threading;
1314
using GitCredentialManager.UI;
1415
using GitCredentialManager.UI.Controls;
@@ -63,6 +64,14 @@ Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(string authority, stri
6364
/// - <c>"resource://{guid}"</c> - Use the user-assigned managed identity with resource ID <c>{guid}</c>.
6465
/// </remarks>
6566
Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(string managedIdentity, string resource);
67+
68+
/// <summary>
69+
/// Acquire a token using workload federation.
70+
/// </summary>
71+
/// <param name="fedOpts">An object containing configuration workload federation.</param>
72+
/// <param name="scopes">Scopes to request.</param>
73+
/// <returns>Authentication result including access token.</returns>
74+
Task<IMicrosoftAuthenticationResult> GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes);
6675
}
6776

6877
public class ServicePrincipalIdentity
@@ -287,7 +296,8 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForServicePrincipalAsy
287296
}
288297
}
289298

290-
public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(string managedIdentity, string resource)
299+
public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(
300+
string managedIdentity, string resource)
291301
{
292302
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
293303

@@ -306,8 +316,88 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsyn
306316
{
307317
Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned
308318
? "Failed to acquire token for system managed identity."
309-
: $"Failed to acquire token for user managed identity '{managedIdentity:D}'.");
319+
: $"Failed to acquire token for user managed identity '{managedIdentity}'.");
320+
Context.Trace.WriteException(ex);
321+
throw;
322+
}
323+
}
324+
325+
public async Task<IMicrosoftAuthenticationResult> GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes)
326+
{
327+
IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(fedOpts);
328+
329+
AuthenticationResult result = await app.AcquireTokenForClient(scopes)
330+
.ExecuteAsync()
331+
.ConfigureAwait(false);
332+
333+
return new MsalResult(result);
334+
}
335+
336+
private async Task<string> GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _)
337+
{
338+
switch (fedOpts.Scenario)
339+
{
340+
case MicrosoftWorkloadFederationScenario.Generic:
341+
Context.Trace.WriteLine("Getting client assertion for generic workload federation scenario...");
342+
if (string.IsNullOrWhiteSpace(fedOpts.GenericClientAssertion))
343+
throw new InvalidOperationException(
344+
"Client assertion must be provided for generic workload federation scenario.");
345+
return fedOpts.GenericClientAssertion;
346+
347+
case MicrosoftWorkloadFederationScenario.ManagedIdentity:
348+
Context.Trace.WriteLine("Getting client assertion for managed identity workload federation scenario...");
349+
var miResult = await GetTokenForManagedIdentityAsync(fedOpts.ManagedIdentityId, fedOpts.Audience);
350+
return miResult.AccessToken;
351+
352+
case MicrosoftWorkloadFederationScenario.GitHubActions:
353+
Context.Trace.WriteLine("Getting client assertion for GitHub Actions workload federation scenario...");
354+
return await GetGitHubOidcToken(fedOpts.GitHubTokenRequestUrl, fedOpts.Audience, fedOpts.GitHubTokenRequestToken);
355+
356+
default:
357+
throw new ArgumentOutOfRangeException(nameof(fedOpts.Scenario), fedOpts.Scenario, "Unsupported workload federation scenario.");
358+
}
359+
}
360+
361+
private async Task<string> GetGitHubOidcToken(Uri requestUri, string audience, string requestToken)
362+
{
363+
using HttpClient http = Context.HttpClientFactory.CreateClient();
364+
365+
UriBuilder ub = new UriBuilder(requestUri);
366+
if (ub.Query.Length > 0) ub.Query += "&";
367+
ub.Query += $"audience={Uri.EscapeDataString(audience)}";
368+
369+
using var request = new HttpRequestMessage(HttpMethod.Get, ub.Uri);
370+
request.AddBearerAuthenticationHeader(requestToken);
371+
372+
Context.Trace.WriteLine($"Requesting GitHub OIDC token from '{request.RequestUri}'...");
373+
Context.Trace.WriteLineSecrets("OIDC request token: {0}", new[] { requestToken });
374+
using HttpResponseMessage response = await http.SendAsync(request);
375+
if (!response.IsSuccessStatusCode)
376+
{
377+
string error = await response.Content.ReadAsStringAsync();
378+
Context.Trace.WriteLine($"Failed to acquire GitHub OIDC token [{response.StatusCode:D} {response.StatusCode}]: {error}");
379+
response.EnsureSuccessStatusCode();
380+
}
381+
382+
string json = await response.Content.ReadAsStringAsync();
383+
384+
try
385+
{
386+
using JsonDocument jsonDoc = JsonDocument.Parse(json);
387+
if (!jsonDoc.RootElement.TryGetProperty("value", out JsonElement tokenElement))
388+
{
389+
throw new InvalidOperationException(
390+
"Invalid response from GitHub OIDC token endpoint: 'value' property not found.");
391+
}
392+
393+
return tokenElement.GetString() ??
394+
throw new InvalidOperationException(
395+
"Invalid response from GitHub OIDC token endpoint: 'value' property is null.");
396+
}
397+
catch (Exception ex)
398+
{
310399
Context.Trace.WriteException(ex);
400+
Context.Trace.WriteLine($"OIDC token response: {json}");
311401
throw;
312402
}
313403
}
@@ -558,6 +648,24 @@ private async Task<IConfidentialClientApplication> CreateConfidentialClientAppli
558648
return app;
559649
}
560650

651+
private async Task<IConfidentialClientApplication> CreateConfidentialClientApplicationAsync(
652+
MicrosoftWorkloadFederationOptions fedOpts)
653+
{
654+
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
655+
656+
Context.Trace.WriteLine($"Creating federated confidential client application for {fedOpts.TenantId}/{fedOpts.ClientId}...");
657+
var appBuilder = ConfidentialClientApplicationBuilder.Create(fedOpts.ClientId)
658+
.WithTenantId(fedOpts.TenantId)
659+
.WithHttpClientFactory(httpFactoryAdaptor)
660+
.WithClientAssertion(reqOpts => GetClientAssertion(fedOpts, reqOpts));
661+
662+
IConfidentialClientApplication app = appBuilder.Build();
663+
664+
await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2);
665+
666+
return app;
667+
}
668+
561669
#endregion
562670

563671
#region Helpers
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System;
2+
3+
namespace GitCredentialManager.Authentication;
4+
5+
public enum MicrosoftWorkloadFederationScenario
6+
{
7+
/// <summary>
8+
/// Federate via pre-computed client assertion.
9+
/// </summary>
10+
Generic,
11+
12+
/// <summary>
13+
/// Federate via an access token for an Entra ID Managed Identity.
14+
/// </summary>
15+
ManagedIdentity,
16+
17+
/// <summary>
18+
/// Federate via a GitHub Actions OIDC token.
19+
/// </summary>
20+
GitHubActions,
21+
}
22+
23+
public class MicrosoftWorkloadFederationOptions
24+
{
25+
public const string DefaultAudience = Constants.DefaultWorkloadFederationAudience;
26+
27+
private string _audience = DefaultAudience;
28+
29+
/// <summary>
30+
/// The workload federation scenario to use.
31+
/// </summary>
32+
public MicrosoftWorkloadFederationScenario Scenario { get; set; }
33+
34+
/// <summary>
35+
/// Tenant ID of the identity to request an access token for.
36+
/// </summary>
37+
public string TenantId { get; set; }
38+
39+
/// <summary>
40+
/// Client ID of the identity to request an access token for.
41+
/// </summary>
42+
public string ClientId { get; set; }
43+
44+
/// <summary>
45+
/// The audience to use when requesting a token.
46+
/// </summary>
47+
/// <remarks>If this is null, the default audience <see cref="DefaultAudience"/> will be used.</remarks>
48+
public string Audience
49+
{
50+
get => _audience;
51+
set => _audience = value ?? DefaultAudience;
52+
}
53+
54+
/// <summary>
55+
/// Generic assertion.
56+
/// </summary>
57+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.Generic"/> federation scenario.</remarks>
58+
public string GenericClientAssertion { get; set; }
59+
60+
/// <summary>
61+
/// The managed identity to request a federated token for, to exchange for an access token.
62+
/// </summary>
63+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.ManagedIdentity"/> federation scenario.</remarks>
64+
public string ManagedIdentityId { get; set; }
65+
66+
/// <summary>
67+
/// GitHub Actions OIDC token request URI.
68+
/// </summary>
69+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.GitHubActions"/> federation scenario.</remarks>
70+
public Uri GitHubTokenRequestUrl { get; set; }
71+
72+
/// <summary>
73+
/// GitHub Actions OIDC token request token.
74+
/// </summary>
75+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.GitHubActions"/> federation scenario.</remarks>
76+
public string GitHubTokenRequestToken { get; set; }
77+
}

src/shared/Core/Constants.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public static class Constants
3131
/// </summary>
3232
public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a");
3333

34+
public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange";
35+
3436
public static class CredentialProtocol
3537
{
3638
public const string NtlmKey = "ntlm";
@@ -130,6 +132,9 @@ public static class EnvironmentVariables
130132
public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS";
131133
public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING";
132134
public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES";
135+
136+
public const string GitHubActionsTokenRequestUrl = "ACTIONS_ID_TOKEN_REQUEST_URL";
137+
public const string GitHubActionsTokenRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
133138
}
134139

135140
public static class Http

0 commit comments

Comments
 (0)