Skip to content

Commit ddc2117

Browse files
ImdsV2: Acquire Entra Token Over mTLS (#5431)
1 parent fdf2038 commit ddc2117

File tree

14 files changed

+596
-15
lines changed

14 files changed

+596
-15
lines changed

src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Microsoft.Identity.Client.Internal.Broker;
1818
using Microsoft.Identity.Client.Internal.ClientCredential;
1919
using Microsoft.Identity.Client.Kerberos;
20+
using Microsoft.Identity.Client.ManagedIdentity.V2;
2021
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
2122
using Microsoft.Identity.Client.UI;
2223
using Microsoft.IdentityModel.Abstractions;
@@ -126,6 +127,7 @@ public string ClientVersion
126127
public Func<AppTokenProviderParameters, Task<AppTokenProviderResult>> AppTokenProvider;
127128

128129
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
130+
internal ICsrFactory CsrFactory { get; set; }
129131

130132
#region ClientCredentials
131133

src/client/Microsoft.Identity.Client/AppConfig/BaseAbstractApplicationBuilder.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.IdentityModel.Abstractions;
1616
using Microsoft.Identity.Client.Internal;
1717
using Microsoft.Identity.Client.Http.Retry;
18+
using Microsoft.Identity.Client.ManagedIdentity.V2;
1819

1920
#if SUPPORTS_SYSTEM_TEXT_JSON
2021
using System.Text.Json;
@@ -39,6 +40,12 @@ internal BaseAbstractApplicationBuilder(ApplicationConfiguration configuration)
3940
{
4041
Config.RetryPolicyFactory = new RetryPolicyFactory();
4142
}
43+
44+
// Ensure the default csr factory is set if the test factory was not provided
45+
if (Config.CsrFactory == null)
46+
{
47+
Config.CsrFactory = new DefaultCsrFactory();
48+
}
4249
}
4350

4451
internal ApplicationConfiguration Config { get; }
@@ -246,6 +253,17 @@ internal T WithRetryPolicyFactory(IRetryPolicyFactory factory)
246253
return (T)this;
247254
}
248255

256+
/// <summary>
257+
/// Internal only: Allows tests to inject a custom csr factory.
258+
/// </summary>
259+
/// <param name="factory">The csr factory to use.</param>
260+
/// <returns>The builder for chaining.</returns>
261+
internal T WithCsrFactory(ICsrFactory factory)
262+
{
263+
Config.CsrFactory = factory;
264+
return (T)this;
265+
}
266+
249267
internal virtual ApplicationConfiguration BuildConfiguration()
250268
{
251269
ResolveAuthority();

src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public virtual async Task<ManagedIdentityResponse> AuthenticateAsync(
8282
method: HttpMethod.Get,
8383
logger: _requestContext.Logger,
8484
doNotThrow: true,
85-
mtlsCertificate: null,
85+
mtlsCertificate: request.MtlsCertificate,
8686
validateServerCertificate: GetValidationCallback(),
8787
cancellationToken: cancellationToken,
8888
retryPolicy: retryPolicy).ConfigureAwait(false);
@@ -97,7 +97,7 @@ public virtual async Task<ManagedIdentityResponse> AuthenticateAsync(
9797
method: HttpMethod.Post,
9898
logger: _requestContext.Logger,
9999
doNotThrow: true,
100-
mtlsCertificate: null,
100+
mtlsCertificate: request.MtlsCertificate,
101101
validateServerCertificate: GetValidationCallback(),
102102
cancellationToken: cancellationToken,
103103
retryPolicy: retryPolicy)

src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityRequest.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Net.Http;
8+
using System.Security.Cryptography.X509Certificates;
89
using Microsoft.Identity.Client.ApiConfig.Parameters;
910
using Microsoft.Identity.Client.Core;
1011
using Microsoft.Identity.Client.OAuth2;
@@ -26,14 +27,21 @@ internal class ManagedIdentityRequest
2627

2728
public RequestType RequestType { get; set; }
2829

29-
public ManagedIdentityRequest(HttpMethod method, Uri endpoint, RequestType requestType = RequestType.ManagedIdentityDefault)
30+
public X509Certificate2 MtlsCertificate { get; set; }
31+
32+
public ManagedIdentityRequest(
33+
HttpMethod method,
34+
Uri endpoint,
35+
RequestType requestType = RequestType.ManagedIdentityDefault,
36+
X509Certificate2 mtlsCertificate = null)
3037
{
3138
Method = method;
3239
_baseEndpoint = endpoint;
3340
Headers = new Dictionary<string, string>();
3441
BodyParameters = new Dictionary<string, string>();
3542
QueryParameters = new Dictionary<string, string>();
3643
RequestType = requestType;
44+
MtlsCertificate = mtlsCertificate;
3745
}
3846

3947
public Uri ComputeUri()

src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2
1010
{
1111
internal class Csr
1212
{
13-
internal static string Generate(string clientId, string tenantId, CuidInfo cuid)
13+
internal static (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid)
1414
{
1515
using (RSA rsa = CreateRsaKeyPair())
1616
{
@@ -30,7 +30,7 @@ internal static string Generate(string clientId, string tenantId, CuidInfo cuid)
3030
"1.3.6.1.4.1.311.90.2.10",
3131
writer.Encode()));
3232

33-
return req.CreateSigningRequestPem();
33+
return (req.CreateSigningRequestPem(), rsa);
3434
}
3535
}
3636

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Security.Cryptography;
5+
6+
namespace Microsoft.Identity.Client.ManagedIdentity.V2
7+
{
8+
internal class DefaultCsrFactory : ICsrFactory
9+
{
10+
public (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid)
11+
{
12+
return Csr.Generate(clientId, tenantId, cuid);
13+
}
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Security.Cryptography;
5+
6+
namespace Microsoft.Identity.Client.ManagedIdentity.V2
7+
{
8+
internal interface ICsrFactory
9+
{
10+
(string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid);
11+
}
12+
}

src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.Identity.Client.Http;
1111
using Microsoft.Identity.Client.Http.Retry;
1212
using Microsoft.Identity.Client.Internal;
13+
using Microsoft.Identity.Client.PlatformsCommon.Shared;
1314
using Microsoft.Identity.Client.Utils;
1415

1516
namespace Microsoft.Identity.Client.ManagedIdentity.V2
@@ -20,11 +21,16 @@ internal class ImdsV2ManagedIdentitySource : AbstractManagedIdentity
2021
public const string ImdsV2ApiVersion = "2.0";
2122
public const string CsrMetadataPath = "/metadata/identity/getplatformmetadata";
2223
public const string CertificateRequestPath = "/metadata/identity/issuecredential";
24+
public const string AcquireEntraTokenPath = "/oauth2/v2.0/token";
2325

2426
public static async Task<CsrMetadata> GetCsrMetadataAsync(
2527
RequestContext requestContext,
2628
bool probeMode)
2729
{
30+
#if NET462
31+
requestContext.Logger.Info(() => "[Managed Identity] IMDSv2 flow is not supported on .NET Framework 4.6.2. Cryptographic operations required for managed identity authentication are unavailable on this platform. Skipping IMDSv2 probe.");
32+
return await Task.FromResult<CsrMetadata>(null).ConfigureAwait(false);
33+
#else
2834
var queryParams = ImdsV2QueryParamsHelper(requestContext);
2935

3036
var headers = new Dictionary<string, string>
@@ -90,6 +96,7 @@ public static async Task<CsrMetadata> GetCsrMetadataAsync(
9096
}
9197

9298
return TryCreateCsrMetadata(response, requestContext.Logger, probeMode);
99+
#endif
93100
}
94101

95102
private static void ThrowProbeFailedException(
@@ -251,11 +258,24 @@ private async Task<CertificateRequestResponse> ExecuteCertificateRequestAsync(st
251258
protected override async Task<ManagedIdentityRequest> CreateRequestAsync(string resource)
252259
{
253260
var csrMetadata = await GetCsrMetadataAsync(_requestContext, false).ConfigureAwait(false);
254-
var csr = Csr.Generate(csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId);
261+
var (csr, privateKey) = _requestContext.ServiceBundle.Config.CsrFactory.Generate(csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId);
255262

256263
var certificateRequestResponse = await ExecuteCertificateRequestAsync(csr).ConfigureAwait(false);
257-
258-
throw new NotImplementedException();
264+
265+
// transform certificateRequestResponse.Certificate to x509 with private key
266+
var mtlsCertificate = CommonCryptographyManager.AttachPrivateKeyToCert(
267+
certificateRequestResponse.Certificate,
268+
privateKey);
269+
270+
ManagedIdentityRequest request = new(HttpMethod.Post, new Uri($"{certificateRequestResponse.MtlsAuthenticationEndpoint}/{certificateRequestResponse.TenantId}{AcquireEntraTokenPath}"));
271+
request.Headers.Add("x-ms-client-request-id", _requestContext.CorrelationId.ToString());
272+
request.BodyParameters.Add("client_id", certificateRequestResponse.ClientId);
273+
request.BodyParameters.Add("grant_type", certificateRequestResponse.Certificate);
274+
request.BodyParameters.Add("scope", "https://management.azure.com/.default");
275+
request.RequestType = RequestType.Imds;
276+
request.MtlsCertificate = mtlsCertificate;
277+
278+
return request;
259279
}
260280

261281
private static string ImdsV2QueryParamsHelper(RequestContext requestContext)

src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/CommonCryptographyManager.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,114 @@ byte[] SignDataAndCacheProvider(string message)
111111
return signedData;
112112
}
113113
}
114+
115+
/// <summary>
116+
/// Attaches a private key to a certificate for use in mTLS authentication.
117+
/// </summary>
118+
/// <param name="certificatePem">The certificate in PEM format</param>
119+
/// <param name="privateKey">The RSA private key to attach</param>
120+
/// <returns>An X509Certificate2 with the private key attached</returns>
121+
/// <exception cref="ArgumentNullException">Thrown when certificatePem or privateKey is null</exception>
122+
/// <exception cref="ArgumentException">Thrown when certificatePem is not a valid PEM certificate</exception>
123+
/// <exception cref="FormatException">Thrown when the certificate cannot be parsed</exception>
124+
internal static X509Certificate2 AttachPrivateKeyToCert(string certificatePem, RSA privateKey)
125+
{
126+
if (string.IsNullOrEmpty(certificatePem))
127+
throw new ArgumentNullException(nameof(certificatePem));
128+
if (privateKey == null)
129+
throw new ArgumentNullException(nameof(privateKey));
130+
131+
X509Certificate2 certificate;
132+
133+
#if NET8_0_OR_GREATER
134+
// .NET 8.0+ has direct PEM parsing support
135+
certificate = X509Certificate2.CreateFromPem(certificatePem);
136+
// Attach the private key and return a new certificate instance
137+
return certificate.CopyWithPrivateKey(privateKey);
138+
#else
139+
// .NET Framework 4.7.2 and .NET Standard 2.0 - manual PEM parsing and private key attachment
140+
certificate = ParseCertificateFromPem(certificatePem);
141+
return AttachPrivateKeyToOlderFrameworks(certificate, privateKey);
142+
#endif
143+
}
144+
145+
#if !NET8_0_OR_GREATER
146+
/// <summary>
147+
/// Parses a certificate from PEM format for older .NET versions.
148+
/// </summary>
149+
/// <param name="certificatePem">The certificate in PEM format</param>
150+
/// <returns>An X509Certificate2 instance</returns>
151+
/// <exception cref="ArgumentException">Thrown when the PEM format is invalid</exception>
152+
/// <exception cref="FormatException">Thrown when the Base64 content cannot be decoded</exception>
153+
private static X509Certificate2 ParseCertificateFromPem(string certificatePem)
154+
{
155+
const string CertBeginMarker = "-----BEGIN CERTIFICATE-----";
156+
const string CertEndMarker = "-----END CERTIFICATE-----";
157+
158+
int startIndex = certificatePem.IndexOf(CertBeginMarker, StringComparison.Ordinal);
159+
if (startIndex == -1)
160+
{
161+
throw new ArgumentException("Invalid PEM format: missing BEGIN CERTIFICATE marker", nameof(certificatePem));
162+
}
163+
164+
startIndex += CertBeginMarker.Length;
165+
int endIndex = certificatePem.IndexOf(CertEndMarker, startIndex, StringComparison.Ordinal);
166+
if (endIndex == -1)
167+
{
168+
throw new ArgumentException("Invalid PEM format: missing END CERTIFICATE marker", nameof(certificatePem));
169+
}
170+
171+
string base64Content = certificatePem.Substring(startIndex, endIndex - startIndex)
172+
.Replace("\r", "")
173+
.Replace("\n", "")
174+
.Replace(" ", "");
175+
176+
if (string.IsNullOrEmpty(base64Content))
177+
{
178+
throw new ArgumentException("Invalid PEM format: no certificate content found", nameof(certificatePem));
179+
}
180+
181+
try
182+
{
183+
byte[] certBytes = Convert.FromBase64String(base64Content);
184+
return new X509Certificate2(certBytes);
185+
}
186+
catch (FormatException ex)
187+
{
188+
throw new FormatException("Invalid PEM format: certificate content is not valid Base64", ex);
189+
}
190+
}
191+
192+
/// <summary>
193+
/// Attaches a private key to a certificate for older .NET Framework versions.
194+
/// This method uses the older RSACng approach for .NET Framework 4.7.2 and .NET Standard 2.0.
195+
/// </summary>
196+
/// <param name="certificate">The certificate without private key</param>
197+
/// <param name="privateKey">The RSA private key to attach</param>
198+
/// <returns>An X509Certificate2 with the private key attached</returns>
199+
/// <exception cref="NotSupportedException">Thrown when private key attachment fails</exception>
200+
private static X509Certificate2 AttachPrivateKeyToOlderFrameworks(X509Certificate2 certificate, RSA privateKey)
201+
{
202+
// For older frameworks, we need to use the legacy approach with RSACryptoServiceProvider
203+
// First, export the RSA parameters from the provided private key
204+
var parameters = privateKey.ExportParameters(includePrivateParameters: true);
205+
206+
// Create a new RSACryptoServiceProvider with the correct key size
207+
int keySize = parameters.Modulus.Length * 8;
208+
using (var rsaProvider = new RSACryptoServiceProvider(keySize))
209+
{
210+
// Import the parameters into the new provider
211+
rsaProvider.ImportParameters(parameters);
212+
213+
// Create a new certificate instance from the raw data
214+
var certWithPrivateKey = new X509Certificate2(certificate.RawData);
215+
216+
// Assign the private key using the legacy property
217+
certWithPrivateKey.PrivateKey = rsaProvider;
218+
219+
return certWithPrivateKey;
220+
}
221+
}
222+
#endif
114223
}
115224
}

0 commit comments

Comments
 (0)