Skip to content

Commit 43950d9

Browse files
gladjohngladjohnCopilot
authored
Add MSI mTLS PoP support: pure MI + FIC-with-MI (impl for devex #3832) (#3839)
* Add MSI mTLS PoP support: pure MI + FIC-with-MI Implements devex spec #3832: - TokenAcquisition: chain WithMtlsProofOfPossession().WithAttestationSupport() on the pure-MI builder when ProtocolScheme=MTLS_POP. - ConfidentialClientApplicationBuilderExtension: dispatch FIC-with-MI to a bound-assertion delegate that returns ClientSignedAssertion (carrying both the JWT and the MI-minted binding certificate). All other signed-assertion source types still throw IDW10115 (preserved by regression test). - ManagedIdentityClientAssertion: new internal GetSignedAssertionWithBindingAsync that calls AcquireTokenForManagedIdentity(...).WithMtlsProofOfPossession() .WithAttestationSupport() and returns the bound assertion + cert pair. - Reference Microsoft.Identity.Client.KeyAttestation 4.84.1-preview. - IVT from Certificateless to TokenAcquisition (3rd entry in established file). - 2 new unit tests in WithClientCredentialsTests.cs (930 total pass, 0 fail). - 2 new daemon samples: daemon-app-msi-mtls, daemon-app-fic-mtls. No new public API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: rename effectiveToken; bump KeyAttestation to GA 4.84.2 - Rename CancellationToken variable per cpp11nullptr review feedback to avoid confusion with OAuth/auth tokens (effectiveToken -> effectiveCancellationToken in GetSignedAssertionWithBindingAsync). - Bump Microsoft.Identity.Client.KeyAttestation from 4.84.1-preview to GA 4.84.2 so it aligns with MSAL 4.84.2 already on master. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address pr comments --------- Co-authored-by: gladjohn <gladjohn@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 536b733 commit 43950d9

18 files changed

Lines changed: 1053 additions & 13 deletions

Directory.Build.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
<PropertyGroup Label="Common dependency versions">
8282
<MicrosoftIdentityModelVersion Condition="'$(MicrosoftIdentityModelVersion)' == ''">8.18.0</MicrosoftIdentityModelVersion>
8383
<MicrosoftIdentityClientVersion Condition="'$(MicrosoftIdentityClientVersion)' == ''">4.84.2</MicrosoftIdentityClientVersion>
84+
<MicrosoftIdentityClientKeyAttestationVersion Condition="'$(MicrosoftIdentityClientKeyAttestationVersion)' == ''">4.84.2</MicrosoftIdentityClientKeyAttestationVersion>
8485
<MicrosoftIdentityAbstractionsVersion Condition="'$(MicrosoftIdentityAbstractionsVersion)' == ''">12.0.0</MicrosoftIdentityAbstractionsVersion>
8586
<FxCopAnalyzersVersion>3.3.0</FxCopAnalyzersVersion>
8687
<SystemTextEncodingsWebVersion>4.7.2</SystemTextEncodingsWebVersion>

Microsoft.Identity.Web.sln

Lines changed: 581 additions & 0 deletions
Large diffs are not rendered by default.

src/Microsoft.Identity.Web.Certificateless/ClientAssertionProviderBase.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,31 @@ public DateTimeOffset? Expiry
5151
return _clientAssertion?.Expiry;
5252
}
5353
}
54+
55+
/// <summary>
56+
/// Indicates whether this provider can produce a binding certificate alongside its signed
57+
/// assertion, enabling the outer confidential client to issue mTLS Proof-of-Possession
58+
/// tokens. Defaults to <c>false</c>; providers that opt in must override this property to
59+
/// <c>true</c> and override <see cref="GetSignedAssertionWithBindingAsync"/> to return a
60+
/// non-null <see cref="ClientSignedAssertion"/>.
61+
/// </summary>
62+
public virtual bool SupportsTokenBinding => false;
63+
64+
/// <summary>
65+
/// Acquires a signed assertion together with its binding certificate, used by
66+
/// confidential clients configured for mTLS Proof-of-Possession. Returns <c>null</c>
67+
/// when the provider does not support token binding (the default).
68+
/// </summary>
69+
/// <param name="assertionRequestOptions">Input options populated by MSAL.</param>
70+
/// <param name="cancellationToken">Cancellation token.</param>
71+
/// <returns>
72+
/// The signed assertion paired with its binding certificate, or <c>null</c> if the
73+
/// provider does not support token binding. Providers that return non-null must also
74+
/// return <c>true</c> from <see cref="SupportsTokenBinding"/>.
75+
/// </returns>
76+
public virtual Task<ClientSignedAssertion?> GetSignedAssertionWithBindingAsync(
77+
AssertionRequestOptions? assertionRequestOptions,
78+
CancellationToken cancellationToken = default)
79+
=> Task.FromResult<ClientSignedAssertion?>(null);
5480
}
5581
}

src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Security.Cryptography.X509Certificates;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.Extensions.Logging;
89
using Microsoft.Identity.Client;
910
using Microsoft.Identity.Client.AppConfig;
1011
using Microsoft.Identity.Client.Extensibility;
12+
using Microsoft.Identity.Client.KeyAttestation;
1113
using Microsoft.Identity.Web.Certificateless;
1214
using Microsoft.Identity.Web.TestOnly;
1315

@@ -112,25 +114,85 @@ internal ManagedIdentityClientAssertion(
112114
protected override async Task<ClientAssertion> GetClientAssertionAsync(
113115
AssertionRequestOptions? assertionRequestOptions)
114116
{
115-
// Start the MI token request for the token-exchange audience
117+
var result = await AcquireManagedIdentityTokenAsync(
118+
assertionRequestOptions,
119+
bindToCertificate: false,
120+
cancellationToken: default)
121+
.ConfigureAwait(false);
122+
123+
return new ClientAssertion(result.AccessToken, result.ExpiresOn);
124+
}
125+
126+
/// <summary>
127+
/// Returns <c>true</c>: managed identity provides a binding certificate alongside the
128+
/// federated assertion via MSAL's IMDS V2 mTLS PoP flow.
129+
/// </summary>
130+
public override bool SupportsTokenBinding => true;
131+
132+
/// <summary>
133+
/// Acquires a managed identity token bound to a binding certificate via mTLS PoP,
134+
/// returning both the assertion and the binding certificate so MSAL can pin the outer
135+
/// confidential client request to the same certificate (FIC + mTLS PoP, two-leg flow).
136+
/// </summary>
137+
/// <remarks>
138+
/// Used when the consuming confidential client has token binding enabled (e.g.,
139+
/// <c>AuthorizationHeaderProviderOptions.ProtocolScheme = "MTLS_POP"</c>). Requires
140+
/// MSAL.NET key-attestation support and an Azure VM / Arc-hosted managed identity
141+
/// capable of returning a <see cref="AuthenticationResult.BindingCertificate"/>.
142+
/// </remarks>
143+
public override async Task<ClientSignedAssertion?> GetSignedAssertionWithBindingAsync(
144+
AssertionRequestOptions? assertionRequestOptions,
145+
CancellationToken cancellationToken = default)
146+
{
147+
var result = await AcquireManagedIdentityTokenAsync(
148+
assertionRequestOptions,
149+
bindToCertificate: true,
150+
cancellationToken: cancellationToken)
151+
.ConfigureAwait(false);
152+
153+
// MSAL guarantees BindingCertificate is non-null when WithMtlsProofOfPossession()
154+
// succeeds; failure to bind surfaces as an MsalServiceException from ExecuteAsync.
155+
return new ClientSignedAssertion
156+
{
157+
Assertion = result.AccessToken,
158+
TokenBindingCertificate = result.BindingCertificate!,
159+
};
160+
}
161+
162+
/// <summary>
163+
/// Builds and executes the underlying managed-identity token request shared by both the
164+
/// bearer (<see cref="GetClientAssertionAsync"/>) and mTLS PoP
165+
/// (<see cref="GetSignedAssertionWithBindingAsync"/>) code paths.
166+
/// </summary>
167+
private async Task<AuthenticationResult> AcquireManagedIdentityTokenAsync(
168+
AssertionRequestOptions? assertionRequestOptions,
169+
bool bindToCertificate,
170+
CancellationToken cancellationToken)
171+
{
116172
var miBuilder = _managedIdentityApplication
117173
.AcquireTokenForManagedIdentity(_tokenExchangeUrl);
118174

119-
if (assertionRequestOptions is not null)
175+
if (bindToCertificate)
120176
{
121-
// Propagate claims into the MI token request.
122-
// This also forces MSAL to bypass the MI token cache when claims are present.
123-
if (!string.IsNullOrEmpty(assertionRequestOptions.Claims))
124-
{
125-
miBuilder.WithClaims(assertionRequestOptions.Claims);
126-
}
177+
miBuilder = miBuilder
178+
.WithMtlsProofOfPossession()
179+
.WithAttestationSupport();
127180
}
128181

129-
var result = await miBuilder
130-
.ExecuteAsync(assertionRequestOptions?.CancellationToken ?? CancellationToken.None)
131-
.ConfigureAwait(false);
182+
// Propagate claims into the MI token request.
183+
// This also forces MSAL to bypass the MI token cache when claims are present.
184+
if (!string.IsNullOrEmpty(assertionRequestOptions?.Claims))
185+
{
186+
miBuilder.WithClaims(assertionRequestOptions!.Claims);
187+
}
132188

133-
return new ClientAssertion(result.AccessToken, result.ExpiresOn);
189+
CancellationToken effectiveCancellationToken = cancellationToken != default
190+
? cancellationToken
191+
: assertionRequestOptions?.CancellationToken ?? CancellationToken.None;
192+
193+
return await miBuilder
194+
.ExecuteAsync(effectiveCancellationToken)
195+
.ConfigureAwait(false);
134196
}
135197

136198
private void Log(

src/Microsoft.Identity.Web.Certificateless/Microsoft.Identity.Web.Certificateless.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsLoggingAbstractionsVersion)" />
2323
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens " Version="$(MicrosoftIdentityModelVersion)" />
2424
<PackageReference Include="Microsoft.Identity.Client" Version="$(MicrosoftIdentityClientVersion)" />
25+
<PackageReference Include="Microsoft.Identity.Client.KeyAttestation" Version="$(MicrosoftIdentityClientKeyAttestationVersion)" />
2526
</ItemGroup>
2627

2728
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
#nullable enable
2+
override Microsoft.Identity.Web.ManagedIdentityClientAssertion.GetSignedAssertionWithBindingAsync(Microsoft.Identity.Client.AssertionRequestOptions? assertionRequestOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Identity.Client.ClientSignedAssertion?>!
3+
override Microsoft.Identity.Web.ManagedIdentityClientAssertion.SupportsTokenBinding.get -> bool
4+
virtual Microsoft.Identity.Web.ClientAssertionProviderBase.GetSignedAssertionWithBindingAsync(Microsoft.Identity.Client.AssertionRequestOptions? assertionRequestOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Identity.Client.ClientSignedAssertion?>!
5+
virtual Microsoft.Identity.Web.ClientAssertionProviderBase.SupportsTokenBinding.get -> bool

src/Microsoft.Identity.Web.TokenAcquisition/ConfidentialClientApplicationBuilderExtension.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ public static async Task<ConfidentialClientApplicationBuilder> WithClientCredent
3636
return builder.WithCertificate(credential.Certificate);
3737
}
3838

39+
// CachedValue holds the concrete provider instance that the credential loader
40+
// created and cached on the CredentialDescription. Providers opt into mTLS PoP
41+
// by overriding ClientAssertionProviderBase.SupportsTokenBinding and returning
42+
// a ClientSignedAssertion (assertion + binding certificate) from
43+
// GetSignedAssertionWithBindingAsync. Today only ManagedIdentityClientAssertion
44+
// ships with that capability; OIDC IdP / Kubernetes federation providers do not.
45+
if (credential?.CredentialType == CredentialType.SignedAssertion
46+
&& credential.CachedValue is ClientAssertionProviderBase bindingProvider
47+
&& bindingProvider.SupportsTokenBinding)
48+
{
49+
return builder.WithClientAssertion(
50+
async (options, ct) =>
51+
(await bindingProvider
52+
.GetSignedAssertionWithBindingAsync(options, ct)
53+
.ConfigureAwait(false))!);
54+
}
55+
3956
throw new InvalidOperationException(IDWebErrorMessage.MissingTokenBindingCertificate);
4057
}
4158

src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ internal static class IDWebErrorMessage
2626
public const string MissingRequiredScopesForAuthorizationFilter = "IDW10108: RequiredScope Attribute does not contain a value. The scopes need to be set on the controller, the page or action. See https://aka.ms/ms-id-web/required-scope-attribute. ";
2727
public const string ClientCertificatesHaveExpiredOrCannotBeLoaded = "IDW10109: No credential could be loaded. This can happen when certificates passed to the configuration have expired or can't be loaded and the code isn't running on Azure to be able to use Managed Identity, Pod Identity etc. Details: ";
2828
public const string ClientSecretAndCredentialsCannotBeCombined = "IDW10110: ClientSecret top level configuration cannot be combined with ClientCredentials. Instead, add a new entry in the ClientCredentials array describing the secret.";
29-
public const string MissingTokenBindingCertificate = "IDW10115: A signing certificate, which is required for token binding, is missing in loaded credentials.";
29+
public const string MissingTokenBindingCertificate = "IDW10115: Token binding requires either a signing certificate or a binding-aware signed assertion (e.g., from a managed identity supporting mTLS PoP). The loaded credential provides neither.";
3030
public const string TokenBindingRequiresEnabledAppTokenAcquisition = "IDW10116: Token binding requires enabled app token acquisition.";
3131
public const string OpenIdConnectMiddlewareDiagnosticsRequiresDevelopmentEnvironment = "IDW10117: OpenIdConnectMiddlewareDiagnostics logs full protocol messages, including bearer tokens and PII, and must only be enabled when running the code locally.";
3232

src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
using Microsoft.Identity.Abstractions;
2121
using Microsoft.Identity.Client;
2222
using Microsoft.Identity.Client.Extensibility;
23+
using Microsoft.Identity.Client.KeyAttestation;
2324
using Microsoft.Identity.Web.Experimental;
2425
using Microsoft.Identity.Web.Extensibility;
2526
using Microsoft.Identity.Web.TestOnly;
@@ -629,6 +630,13 @@ private async Task<AuthenticationResult> GetAuthenticationResultForAppInternalAs
629630

630631
var miBuilder = managedIdApp.AcquireTokenForManagedIdentity(scope);
631632

633+
if (isTokenBinding)
634+
{
635+
miBuilder = miBuilder
636+
.WithMtlsProofOfPossession()
637+
.WithAttestationSupport();
638+
}
639+
632640
if (!string.IsNullOrEmpty(tokenAcquisitionOptions.Claims))
633641
{
634642
miBuilder.WithClaims(tokenAcquisitionOptions.Claims);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Identity.Abstractions;
7+
using Microsoft.Identity.Web;
8+
using System.Net;
9+
10+
// Federated Identity Credentials (FIC) backed by Managed Identity, exchanged for an
11+
// app token via mTLS Proof-of-Possession.
12+
//
13+
// Two-leg flow:
14+
// Leg 1 — Managed Identity mints a binding cert + signed assertion (audience: AzureADTokenExchange).
15+
// Leg 2 — Entra ID exchanges that assertion for a resource access token, pinning it to the same cert.
16+
//
17+
// Trigger: AuthorizationHeaderProviderOptions.ProtocolScheme = "MTLS_POP" (in appsettings.json
18+
// under AcquireTokenOptions, with RequestAppToken = true) plus ClientCredentials of type
19+
// SignedAssertionFromManagedIdentity.
20+
21+
var factory = TokenAcquirerFactory.GetDefaultInstance();
22+
23+
factory.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning));
24+
25+
factory.Services.AddDownstreamApi(
26+
"AzureKeyVault",
27+
factory.Configuration.GetSection("AzureKeyVault"));
28+
29+
IServiceProvider sp = factory.Build();
30+
IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();
31+
32+
HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault");
33+
34+
if (response.StatusCode != HttpStatusCode.OK)
35+
{
36+
Console.WriteLine($"Vault returned {(int)response.StatusCode} {response.ReasonPhrase}");
37+
return;
38+
}
39+
40+
Console.WriteLine("Secret retrieved successfully via FIC + mTLS PoP.");

0 commit comments

Comments
 (0)