Skip to content

Commit 7dec9e9

Browse files
committed
wif: Add initial implementation of Workload Identity Federation
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
1 parent 92a1fd6 commit 7dec9e9

File tree

6 files changed

+615
-2
lines changed

6 files changed

+615
-2
lines changed

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
using System.IO;
44
using System.Linq;
55
using System.Net.Http;
6+
using System.Net.Http.Json;
67
using System.Security.Cryptography.X509Certificates;
78
using System.Threading.Tasks;
89
using GitCredentialManager.Interop.Windows.Native;
910
using Microsoft.Identity.Client;
1011
using Microsoft.Identity.Client.Extensions.Msal;
1112
using System.Text;
13+
using System.Text.Json;
1214
using System.Threading;
1315
using GitCredentialManager.UI;
1416
using GitCredentialManager.UI.Controls;
@@ -63,6 +65,14 @@ Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(string authority, stri
6365
/// - <c>"resource://{guid}"</c> - Use the user-assigned managed identity with resource ID <c>{guid}</c>.
6466
/// </remarks>
6567
Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(string managedIdentity, string resource);
68+
69+
/// <summary>
70+
/// Acquire a token using workload federation.
71+
/// </summary>
72+
/// <param name="fedOpts">An object containing configuration workload federation.</param>
73+
/// <param name="scopes">Scopes to request.</param>
74+
/// <returns>Authentication result including access token.</returns>
75+
Task<IMicrosoftAuthenticationResult> GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes);
6676
}
6777

6878
public class ServicePrincipalIdentity
@@ -287,7 +297,8 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForServicePrincipalAsy
287297
}
288298
}
289299

290-
public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(string managedIdentity, string resource)
300+
public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsync(
301+
string managedIdentity, string resource)
291302
{
292303
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
293304

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

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

563672
#region Helpers
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
/// <summary>
28+
/// The workload federation scenario to use.
29+
/// </summary>
30+
public MicrosoftWorkloadFederationScenario Scenario { get; set; }
31+
32+
/// <summary>
33+
/// Tenant ID of the identity to request an access token for.
34+
/// </summary>
35+
public string TenantId { get; set; }
36+
37+
/// <summary>
38+
/// Client ID of the identity to request an access token for.
39+
/// </summary>
40+
public string ClientId { get; set; }
41+
42+
/// <summary>
43+
/// The audience to use when requesting a token.
44+
/// </summary>
45+
/// <remarks>If this is null, the default audience <see cref="DefaultAudience"/> will be used.</remarks>
46+
public string Audience
47+
{
48+
get;
49+
set => field = value ?? DefaultAudience;
50+
} = DefaultAudience;
51+
52+
/// <summary>
53+
/// Generic assertion.
54+
/// </summary>
55+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.Generic"/> federation scenario.</remarks>
56+
public string GenericClientAssertion { get; set; }
57+
58+
/// <summary>
59+
/// The managed identity to request a federated token for, to exchange for an access token.
60+
/// </summary>
61+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.ManagedIdentity"/> federation scenario.</remarks>
62+
public string ManagedIdentityId { get; set; }
63+
64+
/// <summary>
65+
/// GitHub Actions OIDC token request URI.
66+
/// </summary>
67+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.GitHubActions"/> federation scenario.</remarks>
68+
public Uri GitHubTokenRequestUrl { get; set; }
69+
70+
/// <summary>
71+
/// GitHub Actions OIDC token request token.
72+
/// </summary>
73+
/// <remarks>Used with the <see cref="MicrosoftWorkloadFederationScenario.GitHubActions"/> federation scenario.</remarks>
74+
public string GitHubTokenRequestToken { get; set; }
75+
}

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)