Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<PropertyGroup Label="Common dependency versions">
<MicrosoftIdentityModelVersion Condition="'$(MicrosoftIdentityModelVersion)' == ''">8.18.0</MicrosoftIdentityModelVersion>
<MicrosoftIdentityClientVersion Condition="'$(MicrosoftIdentityClientVersion)' == ''">4.84.2</MicrosoftIdentityClientVersion>
<MicrosoftIdentityClientKeyAttestationVersion Condition="'$(MicrosoftIdentityClientKeyAttestationVersion)' == ''">4.84.2</MicrosoftIdentityClientKeyAttestationVersion>
<MicrosoftIdentityAbstractionsVersion Condition="'$(MicrosoftIdentityAbstractionsVersion)' == ''">12.0.0</MicrosoftIdentityAbstractionsVersion>
<FxCopAnalyzersVersion>3.3.0</FxCopAnalyzersVersion>
<SystemTextEncodingsWebVersion>4.7.2</SystemTextEncodingsWebVersion>
Expand Down
581 changes: 581 additions & 0 deletions Microsoft.Identity.Web.sln

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,31 @@ public DateTimeOffset? Expiry
return _clientAssertion?.Expiry;
}
}

/// <summary>
/// Indicates whether this provider can produce a binding certificate alongside its signed
/// assertion, enabling the outer confidential client to issue mTLS Proof-of-Possession
/// tokens. Defaults to <c>false</c>; providers that opt in must override this property to
/// <c>true</c> and override <see cref="GetSignedAssertionWithBindingAsync"/> to return a
/// non-null <see cref="ClientSignedAssertion"/>.
/// </summary>
public virtual bool SupportsTokenBinding => false;
Comment thread
gladjohn marked this conversation as resolved.

/// <summary>
/// Acquires a signed assertion together with its binding certificate, used by
/// confidential clients configured for mTLS Proof-of-Possession. Returns <c>null</c>
/// when the provider does not support token binding (the default).
/// </summary>
/// <param name="assertionRequestOptions">Input options populated by MSAL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The signed assertion paired with its binding certificate, or <c>null</c> if the
/// provider does not support token binding. Providers that return non-null must also
/// return <c>true</c> from <see cref="SupportsTokenBinding"/>.
/// </returns>
public virtual Task<ClientSignedAssertion?> GetSignedAssertionWithBindingAsync(
Comment thread
gladjohn marked this conversation as resolved.
AssertionRequestOptions? assertionRequestOptions,
CancellationToken cancellationToken = default)
=> Task.FromResult<ClientSignedAssertion?>(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
// Licensed under the MIT License.

using System;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.AppConfig;
using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.KeyAttestation;
using Microsoft.Identity.Web.Certificateless;
using Microsoft.Identity.Web.TestOnly;

Expand Down Expand Up @@ -112,25 +114,85 @@ internal ManagedIdentityClientAssertion(
protected override async Task<ClientAssertion> GetClientAssertionAsync(
AssertionRequestOptions? assertionRequestOptions)
{
// Start the MI token request for the token-exchange audience
var result = await AcquireManagedIdentityTokenAsync(
assertionRequestOptions,
bindToCertificate: false,
cancellationToken: default)
.ConfigureAwait(false);

return new ClientAssertion(result.AccessToken, result.ExpiresOn);
}

/// <summary>
/// Returns <c>true</c>: managed identity provides a binding certificate alongside the
/// federated assertion via MSAL's IMDS V2 mTLS PoP flow.
/// </summary>
public override bool SupportsTokenBinding => true;

/// <summary>
/// Acquires a managed identity token bound to a binding certificate via mTLS PoP,
/// returning both the assertion and the binding certificate so MSAL can pin the outer
/// confidential client request to the same certificate (FIC + mTLS PoP, two-leg flow).
/// </summary>
/// <remarks>
/// Used when the consuming confidential client has token binding enabled (e.g.,
/// <c>AuthorizationHeaderProviderOptions.ProtocolScheme = "MTLS_POP"</c>). Requires
/// MSAL.NET key-attestation support and an Azure VM / Arc-hosted managed identity
/// capable of returning a <see cref="AuthenticationResult.BindingCertificate"/>.
/// </remarks>
public override async Task<ClientSignedAssertion?> GetSignedAssertionWithBindingAsync(
AssertionRequestOptions? assertionRequestOptions,
CancellationToken cancellationToken = default)
{
var result = await AcquireManagedIdentityTokenAsync(
assertionRequestOptions,
bindToCertificate: true,
cancellationToken: cancellationToken)
.ConfigureAwait(false);

// MSAL guarantees BindingCertificate is non-null when WithMtlsProofOfPossession()
// succeeds; failure to bind surfaces as an MsalServiceException from ExecuteAsync.
return new ClientSignedAssertion
{
Assertion = result.AccessToken,
TokenBindingCertificate = result.BindingCertificate!,
};
}

/// <summary>
/// Builds and executes the underlying managed-identity token request shared by both the
/// bearer (<see cref="GetClientAssertionAsync"/>) and mTLS PoP
/// (<see cref="GetSignedAssertionWithBindingAsync"/>) code paths.
/// </summary>
private async Task<AuthenticationResult> AcquireManagedIdentityTokenAsync(
AssertionRequestOptions? assertionRequestOptions,
bool bindToCertificate,
CancellationToken cancellationToken)
{
var miBuilder = _managedIdentityApplication
.AcquireTokenForManagedIdentity(_tokenExchangeUrl);

if (assertionRequestOptions is not null)
if (bindToCertificate)
{
// Propagate claims into the MI token request.
// This also forces MSAL to bypass the MI token cache when claims are present.
if (!string.IsNullOrEmpty(assertionRequestOptions.Claims))
{
miBuilder.WithClaims(assertionRequestOptions.Claims);
}
miBuilder = miBuilder
.WithMtlsProofOfPossession()
.WithAttestationSupport();
}

var result = await miBuilder
.ExecuteAsync(assertionRequestOptions?.CancellationToken ?? CancellationToken.None)
.ConfigureAwait(false);
// Propagate claims into the MI token request.
// This also forces MSAL to bypass the MI token cache when claims are present.
if (!string.IsNullOrEmpty(assertionRequestOptions?.Claims))
Comment thread
gladjohn marked this conversation as resolved.
{
miBuilder.WithClaims(assertionRequestOptions!.Claims);
}

return new ClientAssertion(result.AccessToken, result.ExpiresOn);
CancellationToken effectiveCancellationToken = cancellationToken != default
? cancellationToken
: assertionRequestOptions?.CancellationToken ?? CancellationToken.None;

return await miBuilder
.ExecuteAsync(effectiveCancellationToken)
.ConfigureAwait(false);
}

private void Log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsLoggingAbstractionsVersion)" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens " Version="$(MicrosoftIdentityModelVersion)" />
<PackageReference Include="Microsoft.Identity.Client" Version="$(MicrosoftIdentityClientVersion)" />
<PackageReference Include="Microsoft.Identity.Client.KeyAttestation" Version="$(MicrosoftIdentityClientKeyAttestationVersion)" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
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?>!
override Microsoft.Identity.Web.ManagedIdentityClientAssertion.SupportsTokenBinding.get -> bool
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?>!
virtual Microsoft.Identity.Web.ClientAssertionProviderBase.SupportsTokenBinding.get -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ public static async Task<ConfidentialClientApplicationBuilder> WithClientCredent
return builder.WithCertificate(credential.Certificate);
}

// CachedValue holds the concrete provider instance that the credential loader
// created and cached on the CredentialDescription. Providers opt into mTLS PoP
// by overriding ClientAssertionProviderBase.SupportsTokenBinding and returning
// a ClientSignedAssertion (assertion + binding certificate) from
// GetSignedAssertionWithBindingAsync. Today only ManagedIdentityClientAssertion
// ships with that capability; OIDC IdP / Kubernetes federation providers do not.
if (credential?.CredentialType == CredentialType.SignedAssertion
&& credential.CachedValue is ClientAssertionProviderBase bindingProvider
&& bindingProvider.SupportsTokenBinding)
{
return builder.WithClientAssertion(
async (options, ct) =>
(await bindingProvider
.GetSignedAssertionWithBindingAsync(options, ct)
.ConfigureAwait(false))!);
}

throw new InvalidOperationException(IDWebErrorMessage.MissingTokenBindingCertificate);
Comment thread
gladjohn marked this conversation as resolved.
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal static class IDWebErrorMessage
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. ";
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: ";
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.";
public const string MissingTokenBindingCertificate = "IDW10115: A signing certificate, which is required for token binding, is missing in loaded credentials.";
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.";
public const string TokenBindingRequiresEnabledAppTokenAcquisition = "IDW10116: Token binding requires enabled app token acquisition.";
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.";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.KeyAttestation;
using Microsoft.Identity.Web.Experimental;
using Microsoft.Identity.Web.Extensibility;
using Microsoft.Identity.Web.TestOnly;
Expand Down Expand Up @@ -629,6 +630,13 @@ private async Task<AuthenticationResult> GetAuthenticationResultForAppInternalAs

var miBuilder = managedIdApp.AcquireTokenForManagedIdentity(scope);

if (isTokenBinding)
{
miBuilder = miBuilder
.WithMtlsProofOfPossession()
.WithAttestationSupport();
}

if (!string.IsNullOrEmpty(tokenAcquisitionOptions.Claims))
{
miBuilder.WithClaims(tokenAcquisitionOptions.Claims);
Expand Down
40 changes: 40 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-fic-mtls/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;
using System.Net;

// Federated Identity Credentials (FIC) backed by Managed Identity, exchanged for an
// app token via mTLS Proof-of-Possession.
//
// Two-leg flow:
// Leg 1 — Managed Identity mints a binding cert + signed assertion (audience: AzureADTokenExchange).
// Leg 2 — Entra ID exchanges that assertion for a resource access token, pinning it to the same cert.
//
// Trigger: AuthorizationHeaderProviderOptions.ProtocolScheme = "MTLS_POP" (in appsettings.json
// under AcquireTokenOptions, with RequestAppToken = true) plus ClientCredentials of type
// SignedAssertionFromManagedIdentity.

var factory = TokenAcquirerFactory.GetDefaultInstance();

factory.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning));

factory.Services.AddDownstreamApi(
"AzureKeyVault",
factory.Configuration.GetSection("AzureKeyVault"));

IServiceProvider sp = factory.Build();
IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();

HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault");

if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine($"Vault returned {(int)response.StatusCode} {response.ReasonPhrase}");
return;
}

Console.WriteLine("Secret retrieved successfully via FIC + mTLS PoP.");
35 changes: 35 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-fic-mtls/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# daemon-app-fic-mtls

Daemon (console) sample that calls Azure Key Vault using **Federated Identity Credentials (FIC)
backed by Managed Identity**, with the resulting app token bound to the MI's binding certificate
via **mTLS Proof-of-Possession** (PoP).

## What this demonstrates

Two-leg flow:

1. **Leg 1 — Managed Identity** (`SignedAssertionFromManagedIdentity`)
- V2 managed identity credential endpoint returns a binding certificate + a signed assertion
whose audience is `api://AzureADTokenExchange`.
2. **Leg 2 — Confidential Client → Entra ID**
- Microsoft.Identity.Web sends that assertion to the application's tenant and asks for the
downstream resource (Key Vault) access token.
- `ProtocolScheme = "MTLS_POP"` pins the outer token to the same binding certificate, so the
downstream HTTPS call to Key Vault uses mTLS.

No app secret, no rotatable certificate.

## Prerequisites

- App registration (`AzureAd:ClientId`) with a **Federated Identity Credential** that trusts the
managed identity in `AzureAd:ClientCredentials[0].ManagedIdentityClientId`.
- The app registration must be granted **Key Vault Secrets User** (or higher) on the target vault.
- Host must expose the V2 managed identity credential endpoint (Azure VM, Arc-enabled server, etc.).

## Run

```bash
dotnet run
```

Expected output: `Secret retrieved successfully via FIC + mTLS PoP.`
29 changes: 29 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-fic-mtls/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "10c419d4-4a50-45b2-aa4e-919fb84df24f",
"ClientId": "a599ce88-0a5f-4a6e-beca-e67d3fc427f4",
"ClientCapabilities": [ "cp1" ],
"ClientCredentials": [
{
"SourceType": "SignedAssertionFromManagedIdentity",
"ManagedIdentityClientId": "4b7a4b0b-ecb2-409e-879a-1e21a15ddaf6"
}
]
},

"AzureKeyVault": {
"BaseUrl": "https://msidlabs.vault.azure.net/",
"RelativePath": "secrets/id4slab1?api-version=7.4",
"RequestAppToken": true,
"ProtocolScheme": "MTLS_POP",
"Scopes": [ "https://vault.azure.net/.default" ]
},

"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Information"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0</TargetFrameworks>
<RootNamespace>Daemon_app_fic_mtls</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>13</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Identity.Web.DownstreamApi\Microsoft.Identity.Web.DownstreamApi.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Identity.Web.TokenAcquisition\Microsoft.Identity.Web.TokenAcquisition.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
36 changes: 36 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-msi-mtls/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;
using System.Net;

// Pure Managed Identity with mTLS Proof-of-Possession.
// Requires an Azure VM / Arc-enabled server that exposes the V2 managed identity
// credential endpoint (returns a binding certificate alongside the access token).
//
// Trigger: AuthorizationHeaderProviderOptions.ProtocolScheme = "MTLS_POP" in appsettings.json
// (under AcquireTokenOptions, with RequestAppToken = true).

var factory = TokenAcquirerFactory.GetDefaultInstance();

factory.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning));

factory.Services.AddDownstreamApi(
"AzureKeyVault",
factory.Configuration.GetSection("AzureKeyVault"));

IServiceProvider sp = factory.Build();
IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();

HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault");

if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine($"Vault returned {(int)response.StatusCode} {response.ReasonPhrase}");
return;
}

Console.WriteLine("Secret retrieved successfully via MSI mTLS PoP.");
Loading
Loading