Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
Expand All @@ -10,6 +11,7 @@
using Microsoft.Identity.Client.Cache.Items;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.ManagedIdentity;
using Microsoft.Identity.Client.ManagedIdentity.V2;
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.Utils;
Expand Down Expand Up @@ -227,6 +229,15 @@ private async Task<AuthenticationResult> SendTokenRequestForManagedIdentityAsync
_managedIdentityParameters.ClientClaims = AuthenticationRequestParameters.ClientClaims;
}

// mTLS PoP is served exclusively by IMDSv2. Mint the binding certificate, then delegate the
// token leg to MSAL's internal TokenClient exchange (the same path CCA uses) so client-originated
// claims, client-capability (CP1) merge, claims-based cache keying, and ESTS error handling are
// inherited rather than re-implemented in a bespoke MI token POST.
if (AuthenticationRequestParameters.IsMtlsPopRequested)
{
return await SendDelegatedImdsV2TokenRequestAsync(logger, cancellationToken).ConfigureAwait(false);
}

ManagedIdentityResponse managedIdentityResponse =
await _managedIdentityClient
.SendTokenRequestForManagedIdentityAsync(AuthenticationRequestParameters.RequestContext, _managedIdentityParameters, cancellationToken)
Expand All @@ -252,6 +263,84 @@ await _managedIdentityClient
return await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse, cancellationToken).ConfigureAwait(false);
}

// Mints the IMDSv2 binding cert, then delegates the token request to MSAL's internal TokenClient
// (the same exchange path CCA uses). Re-mints the binding and retries once when ESTS-R rejects the
// bound cert (invalid_client) or the local mTLS handshake fails (SCHANNEL). The minted cert is
// injected as the mTLS transport cert and the mtls_pop scheme is applied so the result is cert-bound.
private async Task<AuthenticationResult> SendDelegatedImdsV2TokenRequestAsync(
ILoggerAdapter logger,
CancellationToken cancellationToken)
{
logger.Info("[ManagedIdentityRequest] Delegating IMDSv2 token leg to internal TokenClient exchange path.");

string resource = _managedIdentityParameters.Resource;
MsalTokenResponse msalTokenResponse;

try
{
msalTokenResponse = await DelegateImdsV2TokenLegAsync(resource, forceRemint: false, cancellationToken)
.ConfigureAwait(false);
}
catch (MsalServiceException ex) when (
string.Equals(ex.ErrorCode, "invalid_client", StringComparison.OrdinalIgnoreCase) ||
ImdsV2ManagedIdentitySource.IsSchanelFailure(ex))
Comment thread
Copilot marked this conversation as resolved.
{
logger.Info("[ManagedIdentityRequest] mTLS binding rejected (invalid_client/SCHANNEL); re-minting binding certificate and retrying once.");
msalTokenResponse = await DelegateImdsV2TokenLegAsync(resource, forceRemint: true, cancellationToken)
.ConfigureAwait(false);
}

// Normalize the cached scope to the MI request scope so subsequent cache lookups
// (keyed on AuthenticationRequestParameters.Scope) hit, mirroring the bespoke MI path.
msalTokenResponse.Scope = AuthenticationRequestParameters.Scope.AsSingleString();

return await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse, cancellationToken).ConfigureAwait(false);
}

private async Task<MsalTokenResponse> DelegateImdsV2TokenLegAsync(
string resource,
bool forceRemint,
CancellationToken cancellationToken)
{
MtlsBindingInfo binding = await _managedIdentityClient
.AcquireImdsV2MtlsBindingAsync(
AuthenticationRequestParameters.RequestContext,
_managedIdentityParameters,
forceRemint,
cancellationToken)
.ConfigureAwait(false);

// Inject the IMDS-minted cert as the request mTLS transport cert and apply the mtls_pop scheme
// so TokenClient emits token_type=mtls_pop and the resulting token is bound to the certificate.
AuthenticationRequestParameters.MtlsCertificate = binding.Certificate;
AuthenticationRequestParameters.AuthenticationScheme =
new MtlsPopAuthenticationOperation(binding.Certificate);

// Remember the cert so subsequent cache lookups compute the same x5t#S256 cache key.
_managedIdentityClient.SetRuntimeMtlsBindingCertificate(binding.Certificate);

// grant_type is not added by TokenClient; client_id overrides AppConfig.ClientId
// (the SAMI placeholder) with the canonical GUID from the binding. Client-originated claims
// are emitted automatically by TokenClient via ClaimsAndClientCapabilities.
var bodyParameters = new Dictionary<string, string>
{
[OAuth2Parameter.GrantType] = OAuth2GrantType.ClientCredentials,
[OAuth2Parameter.ClientId] = binding.ClientId
};

string tokenEndpoint = binding.Endpoint.TrimEnd('/') + ImdsV2ManagedIdentitySource.AcquireEntraTokenPath;
string scopeOverride = resource.TrimEnd('/') + "/.default";

var tokenClient = new TokenClient(AuthenticationRequestParameters);
Comment thread
bgavrilMS marked this conversation as resolved.

return await tokenClient.SendTokenRequestAsync(
bodyParameters,
scopeOverride: scopeOverride,
tokenEndpointOverride: tokenEndpoint,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
}

private async Task<MsalAccessTokenCacheItem> GetCachedAccessTokenAsync()
{
MsalAccessTokenCacheItem cachedAccessTokenItem = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ internal async Task<ManagedIdentityResponse> SendTokenRequestForManagedIdentityA
return await msi.AuthenticateAsync(parameters, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Mints (or reuses) the IMDSv2 mTLS binding without sending the token request, so the caller
/// can delegate the token leg to MSAL's internal exchange path (<see cref="OAuth2.TokenClient"/>).
/// mTLS PoP always routes to the IMDSv2 source.
/// </summary>
internal async Task<MtlsBindingInfo> AcquireImdsV2MtlsBindingAsync(
RequestContext requestContext,
AcquireTokenForManagedIdentityParameters parameters,
bool forceRemint,
CancellationToken cancellationToken)
{
// Route through the shared source selection so the same guards apply as the bespoke path
// (e.g. throwing MtlsPopTokenNotSupportedinImdsV1 when only IMDSv1 is available). mTLS PoP
// always resolves to the IMDSv2 source.
AbstractManagedIdentity selected = await GetOrSelectManagedIdentitySourceAsync(
requestContext, isMtlsPopRequested: true, cancellationToken).ConfigureAwait(false);

var source = (ImdsV2ManagedIdentitySource)selected;
return await source
.AcquireMtlsBindingForDelegationAsync(parameters, forceRemint, cancellationToken)
.ConfigureAwait(false);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
}

// This method selects the managed identity source for token acquisition.
// It does NOT probe IMDS. It uses the cached explicit discovery result if available,
// otherwise checks environment variables, and defaults to IMDS without probing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ public override async Task<ManagedIdentityResponse> AuthenticateAsync(
/// </summary>
/// <param name="ex"></param>
/// <returns></returns>
private static bool IsSchanelFailure(MsalServiceException ex)
internal static bool IsSchanelFailure(MsalServiceException ex)
{
for (Exception e = ex; e != null; e = e.InnerException)
{
Expand Down Expand Up @@ -333,6 +333,51 @@ private async Task<CertificateRequestResponse> ExecuteCertificateRequestAsync(
}

protected override async Task<ManagedIdentityRequest> CreateRequestAsync(string resource)
{
// Mint (or reuse) the mTLS binding. NOTE: for mTLS PoP the token leg is delegated to
// MSAL's internal TokenClient exchange (see ManagedIdentityAuthRequest); IMDSv2 is selected
// solely for PoP today, so this bespoke token request is retained only for completeness.
MtlsBindingInfo mtlsBinding = await AcquireMtlsBindingAsync().ConfigureAwait(false);

X509Certificate2 bindingCertificate = mtlsBinding.Certificate;
string endpointBaseForToken = mtlsBinding.Endpoint;
string clientIdForToken = mtlsBinding.ClientId;

ManagedIdentityRequest request = new ManagedIdentityRequest(
HttpMethod.Post,
new Uri(endpointBaseForToken + AcquireEntraTokenPath));

Dictionary<string, string> idParams = MsalIdHelper.GetMsalIdParameters(_requestContext.Logger);

foreach (KeyValuePair<string, string> idParam in idParams)
{
request.Headers[idParam.Key] = idParam.Value;
}

request.Headers.Add(OAuth2Header.XMsCorrelationId, _requestContext.CorrelationId.ToString());
request.Headers.Add(ThrottleCommon.ThrottleRetryAfterHeaderName, ThrottleCommon.ThrottleRetryAfterHeaderValue);
request.Headers.Add(OAuth2Header.RequestCorrelationIdInResponse, "true");

var tokenType = _isMtlsPopRequested ? Constants.MtlsPoPTokenType : Constants.BearerTokenType;

request.BodyParameters.Add("client_id", clientIdForToken);
request.BodyParameters.Add("grant_type", OAuth2GrantType.ClientCredentials);
request.BodyParameters.Add("scope", resource.TrimEnd('/') + "/.default");
request.BodyParameters.Add("token_type", tokenType);

request.RequestType = RequestType.STS;
request.MtlsCertificate = bindingCertificate;

return request;
}

/// <summary>
/// Performs the cert-mint flow (/getplatformmetadata + /issuecredential) and returns the
/// resulting mTLS binding (cert + ESTS-R endpoint + canonical client_id). Extracted so the
/// binding can be reused by the internal-exchange delegation path without building the
/// bespoke token request.
/// </summary>
private async Task<MtlsBindingInfo> AcquireMtlsBindingAsync()
{
CsrMetadata csrMetadata = await GetCsrMetadataAsync(_requestContext).ConfigureAwait(false);

Expand Down Expand Up @@ -410,36 +455,30 @@ protected override async Task<ManagedIdentityRequest> CreateRequestAsync(string
_requestContext.Logger)
.ConfigureAwait(false);

X509Certificate2 bindingCertificate = mtlsBinding.Certificate;
string endpointBaseForToken = mtlsBinding.Endpoint;
string clientIdForToken = mtlsBinding.ClientId;

ManagedIdentityRequest request = new ManagedIdentityRequest(
HttpMethod.Post,
new Uri(endpointBaseForToken + AcquireEntraTokenPath));
return mtlsBinding;
}

Dictionary<string, string> idParams = MsalIdHelper.GetMsalIdParameters(_requestContext.Logger);
/// <summary>
/// Mint-only entrypoint used by the internal-exchange delegation path. Sets the attestation
/// provider and mTLS-PoP flag from the request parameters, optionally evicts a rejected cert
/// (invalid_client / SCHANNEL re-mint), and returns the mTLS binding. Does NOT send the token request.
/// </summary>
internal async Task<MtlsBindingInfo> AcquireMtlsBindingForDelegationAsync(
ApiConfig.Parameters.AcquireTokenForManagedIdentityParameters parameters,
bool forceRemint,
CancellationToken cancellationToken)
{
_attestationTokenProvider = parameters.AttestationTokenProvider;
_isMtlsPopRequested = true;

foreach (KeyValuePair<string, string> idParam in idParams)
if (forceRemint && _mtlsCache is MtlsBindingCache bindingCache)
{
request.Headers[idParam.Key] = idParam.Value;
bindingCache.RemoveBadCert(GetMtlsCertCacheKey(), _requestContext.Logger);
}

request.Headers.Add(OAuth2Header.XMsCorrelationId, _requestContext.CorrelationId.ToString());
request.Headers.Add(ThrottleCommon.ThrottleRetryAfterHeaderName, ThrottleCommon.ThrottleRetryAfterHeaderValue);
request.Headers.Add(OAuth2Header.RequestCorrelationIdInResponse, "true");

var tokenType = _isMtlsPopRequested ? Constants.MtlsPoPTokenType : Constants.BearerTokenType;

request.BodyParameters.Add("client_id", clientIdForToken);
request.BodyParameters.Add("grant_type", OAuth2GrantType.ClientCredentials);
request.BodyParameters.Add("scope", resource.TrimEnd('/') + "/.default");
request.BodyParameters.Add("token_type", tokenType);
cancellationToken.ThrowIfCancellationRequested();

request.RequestType = RequestType.STS;
request.MtlsCertificate = bindingCertificate;

return request;
return await AcquireMtlsBindingAsync().ConfigureAwait(false);
}

/// <summary>
Expand Down
46 changes: 38 additions & 8 deletions src/client/Microsoft.Identity.Lab.Api/Http/MockHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
Expand Down Expand Up @@ -202,6 +202,26 @@ public static string GetMsiSuccessfulResponse(
"\"token_type\":\"" + tokenType + "\",\"client_id\":\"client_id\"}";
}

/// <summary>
/// Creates a successful ESTS-R token response for the IMDSv2 mTLS-PoP token leg, which is now
/// served by MSAL's internal TokenClient exchange against ESTS-R. Unlike the IMDS endpoint
/// (which emits <c>expires_in</c> as an absolute Unix timestamp), ESTS-R returns <c>expires_in</c>
/// as a relative lifetime in seconds, so this mock mirrors the real ESTS-R response shape.
/// </summary>
/// <param name="accessToken">The access token value to return.</param>
/// <param name="expiresInSeconds">The relative token lifetime, in seconds.</param>
/// <param name="tokenType">The token type to return (defaults to <c>mtls_pop</c>).</param>
/// <returns>A JSON ESTS-R token response string.</returns>
internal static string GetImdsV2EntraTokenResponse(
string accessToken = TestConstants.ATSecret,
string expiresInSeconds = "3599",
string tokenType = "mtls_pop")
{
return
"{\"token_type\":\"" + tokenType + "\",\"expires_in\":\"" + expiresInSeconds + "\"," +
"\"access_token\":\"" + accessToken + "\",\"resource\":\"https://management.azure.com/\"}";
}

/// <summary>
/// Creates malformed JSON based on a managed identity success response.
/// </summary>
Expand Down Expand Up @@ -1128,9 +1148,13 @@ public static MockHttpMessageHandler MockCertificateRequestResponse(
/// Creates a mock Entra token response handler for IMDS v2 token acquisition.
/// </summary>
/// <param name="identityLoggerAdapter">The logger adapter used to populate expected MSAL ID headers.</param>
/// <param name="expectedClaims">When set, asserts that the delegated ESTS-R POST body carries this <c>claims</c> value.</param>
/// <param name="responseOverride">When set, returns this response instead of the default success body (e.g. an <c>invalid_client</c> failure).</param>
/// <returns>A configured <see cref="MockHttpMessageHandler"/>.</returns>
internal static MockHttpMessageHandler MockImdsV2EntraTokenRequestResponse(
IdentityLoggerAdapter identityLoggerAdapter)
IdentityLoggerAdapter identityLoggerAdapter,
string expectedClaims = null,
HttpResponseMessage responseOverride = null)
{
IDictionary<string, string> expectedPostData = new Dictionary<string, string>();
IDictionary<string, string> expectedRequestHeaders = new Dictionary<string, string>
Expand All @@ -1139,7 +1163,7 @@ internal static MockHttpMessageHandler MockImdsV2EntraTokenRequestResponse(
};
IList<string> presentRequestHeaders = new List<string>
{
OAuth2Header.XMsCorrelationId
OAuth2Header.CorrelationId
};

var idParams = MsalIdHelper.GetMsalIdParameters(identityLoggerAdapter);
Expand All @@ -1150,16 +1174,21 @@ internal static MockHttpMessageHandler MockImdsV2EntraTokenRequestResponse(

expectedPostData.Add("token_type", "mtls_pop");

if (!string.IsNullOrEmpty(expectedClaims))
{
expectedPostData.Add("claims", expectedClaims);
}

var handler = new MockHttpMessageHandler()
{
ExpectedUrl = $"{TestConstants.MtlsAuthenticationEndpoint}/{TestConstants.TenantId}{ImdsV2ManagedIdentitySource.AcquireEntraTokenPath}",
ExpectedMethod = HttpMethod.Post,
ExpectedPostData = expectedPostData,
ExpectedRequestHeaders = expectedRequestHeaders,
PresentRequestHeaders = presentRequestHeaders,
ResponseMessage = new HttpResponseMessage(HttpStatusCode.OK)
ResponseMessage = responseOverride ?? new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(GetMsiSuccessfulResponse(imdsV2: true)),
Content = new StringContent(GetImdsV2EntraTokenResponse()),
}
};

Expand Down Expand Up @@ -1271,7 +1300,7 @@ internal static MockHttpMessageHandler MockImdsV2EntraTokenRequestResponseExpect
};
IList<string> presentRequestHeaders = new List<string>
{
OAuth2Header.XMsCorrelationId
OAuth2Header.CorrelationId
Comment thread
Robbie-Microsoft marked this conversation as resolved.
};

var idParams = MsalIdHelper.GetMsalIdParameters(identityLoggerAdapter);
Expand All @@ -1293,7 +1322,7 @@ internal static MockHttpMessageHandler MockImdsV2EntraTokenRequestResponseExpect
PresentRequestHeaders = presentRequestHeaders,
ResponseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(GetMsiSuccessfulResponse(imdsV2: true)),
Content = new StringContent(GetImdsV2EntraTokenResponse(tokenType: tokenType)),
}
};
}
Expand Down Expand Up @@ -1418,7 +1447,7 @@ public static MockHttpMessageHandler MockImdsV2EntraTokenResponse(
},
ResponseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(GetMsiSuccessfulResponse(imdsV2: true)),
Content = new StringContent(GetImdsV2EntraTokenResponse()),
}
};

Expand Down Expand Up @@ -1464,3 +1493,4 @@ public static void AddFullV2FlowHandlers(
#endregion
}
}

Loading
Loading