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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
<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>
<MicrosoftIdentityAbstractionsVersion Condition="'$(MicrosoftIdentityAbstractionsVersion)' == ''">12.1.0</MicrosoftIdentityAbstractionsVersion>
<FxCopAnalyzersVersion>3.3.0</FxCopAnalyzersVersion>
<SystemTextEncodingsWebVersion>4.7.2</SystemTextEncodingsWebVersion>
<AzureSecurityKeyVaultSecretsVersion>4.6.0</AzureSecurityKeyVaultSecretsVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.AppConfig;
using static Microsoft.Identity.Web.TokenAcquisition;

namespace Microsoft.Identity.Web
Expand Down Expand Up @@ -66,6 +67,13 @@ public static async Task<ConfidentialClientApplicationBuilder> WithClientCredent
case CredentialType.SignedAssertion:
return builder.WithClientAssertion((credential.CachedValue as ClientAssertionProviderBase)!.GetSignedAssertionAsync);
case CredentialType.Certificate:
if (credential.UseBoundCredential && credential.Certificate is not null)
{
return builder.WithCertificate(
credential.Certificate,
new CertificateOptions { SendCertificateOverMtls = true });
}

return builder.WithCertificate(credential.Certificate);
case CredentialType.Secret:
return builder.WithClientSecret(credential.ClientSecret);
Expand Down
62 changes: 62 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-cert-bound/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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;
using System.Text.Json;

// Phase-1 dev harness for the Bearer-with-bound-credential opt-in.
// Uses the MSAL SNI lab app + lab cert. Calls Key Vault as the downstream
// API. The single ClientCredential entry has UseBoundCredential=true, so
// IdWeb should route the token request to the Entra mTLS endpoint over
// mTLS instead of producing a client-assertion JWT.

var factory = TokenAcquirerFactory.GetDefaultInstance();

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

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

IServiceProvider sp = factory.Build();

var acquirerFactory = sp.GetRequiredService<ITokenAcquirerFactory>();
var acquirer = acquirerFactory.GetTokenAcquirer();
var tokenResult = await acquirer.GetTokenForAppAsync("https://vault.azure.net/.default");

Console.WriteLine();
Console.WriteLine("=== Bearer-with-bound-credential token result ===");
Console.WriteLine($"Expires on : {tokenResult.ExpiresOn:u}");
Console.WriteLine($"AccessToken length : {tokenResult.AccessToken?.Length ?? 0}");
if (!string.IsNullOrEmpty(tokenResult.AccessToken))
{
Console.WriteLine($"AccessToken prefix : {tokenResult.AccessToken[..Math.Min(40, tokenResult.AccessToken.Length)]}...");
}
Console.WriteLine();

IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();
HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault");

Console.WriteLine($"Vault status code : {(int)response.StatusCode} {response.ReasonPhrase}");

if (response.StatusCode == HttpStatusCode.OK)
{
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("value", out var valueElement) &&
valueElement.ValueKind == JsonValueKind.String)
{
string secret = valueElement.GetString()!;
Console.WriteLine($"Secret retrieved : {(string.IsNullOrEmpty(secret) ? "EMPTY" : "OK (non-empty)")}");
}
}
else
{
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Body : {body}");
}
29 changes: 29 additions & 0 deletions tests/DevApps/daemon-app/daemon-app-cert-bound/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "bea21ebe-8b64-4d06-9f6d-6a889b120a7c",
"ClientId": "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e",
"ClientCredentials": [
{
"SourceType": "StoreWithThumbprint",
"CertificateStorePath": "CurrentUser/My",
"CertificateThumbprint": "51D8BA89C6570E54926BD9812C2E127BB8F862C7",
"UseBoundCredential": true
}
]
},

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

"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.Identity": "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>DaemonAppCertBound</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>
Original file line number Diff line number Diff line change
Expand Up @@ -554,5 +554,104 @@ public async Task WithBindingCertificateAsync_FicWithFileBasedAssertion_StillThr

#endregion

#region UseBoundCredential dispatch tests

[Fact]
public async Task WithClientCredentialsAsync_CertificateWithUseBoundCredentialTrue_ReturnsBuilderAsync()
{
// Arrange
var logger = Substitute.For<ILogger<CredentialsProvider>>();
var credLoader = Substitute.For<ICredentialsLoader>();
var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
.WithAuthority(TestConstants.AuthorityCommonTenant);

var credentialDescription = new CredentialDescription
{
SourceType = CredentialSource.StoreWithThumbprint,
CertificateThumbprint = "test-thumbprint",
CertificateStorePath = "CurrentUser/My",
UseBoundCredential = true,
};

var testCertificate = Base64EncodedCertificateLoader.LoadFromBase64Encoded(
TestConstants.CertificateX5cWithPrivateKey,
TestConstants.CertificateX5cWithPrivateKeyPassword,
X509KeyStorageFlags.DefaultKeySet);

credLoader.LoadCredentialsIfNeededAsync(Arg.Any<CredentialDescription>(), Arg.Any<CredentialSourceLoaderParameters>())
.Returns(args =>
{
var cd = (args[0] as CredentialDescription)!;
cd.Certificate = testCertificate;
return Task.CompletedTask;
});

// Act — the bound-credential path should call
// WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true });
// we verify the dispatch does not throw and a builder comes back.
var result = await builder.WithClientCredentialsAsync(
new MergedOptions()
{
ClientCredentials = new[] { credentialDescription },
},
new CredentialsProvider(logger, credLoader, [], null),
credentialSourceLoaderParameters: null,
isTokenBinding: false);

// Assert
Assert.NotNull(result);
Assert.Same(builder, result);
await credLoader.Received(1).LoadCredentialsIfNeededAsync(credentialDescription, null);
}

[Fact]
public async Task WithClientCredentialsAsync_CertificateWithUseBoundCredentialFalse_ReturnsBuilderAsync()
{
// Arrange — same setup as the bound-credential test, but with
// UseBoundCredential explicitly false. Guards against regression
// in the legacy (non-mTLS) cert dispatch.
var logger = Substitute.For<ILogger<CredentialsProvider>>();
var credLoader = Substitute.For<ICredentialsLoader>();
var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
.WithAuthority(TestConstants.AuthorityCommonTenant);

var credentialDescription = new CredentialDescription
{
SourceType = CredentialSource.StoreWithThumbprint,
CertificateThumbprint = "test-thumbprint",
CertificateStorePath = "CurrentUser/My",
UseBoundCredential = false,
};

var testCertificate = Base64EncodedCertificateLoader.LoadFromBase64Encoded(
TestConstants.CertificateX5cWithPrivateKey,
TestConstants.CertificateX5cWithPrivateKeyPassword,
X509KeyStorageFlags.DefaultKeySet);

credLoader.LoadCredentialsIfNeededAsync(Arg.Any<CredentialDescription>(), Arg.Any<CredentialSourceLoaderParameters>())
.Returns(args =>
{
var cd = (args[0] as CredentialDescription)!;
cd.Certificate = testCertificate;
return Task.CompletedTask;
});

// Act
var result = await builder.WithClientCredentialsAsync(
new MergedOptions()
{
ClientCredentials = new[] { credentialDescription },
},
new CredentialsProvider(logger, credLoader, [], null),
credentialSourceLoaderParameters: null,
isTokenBinding: false);

// Assert
Assert.NotNull(result);
Assert.Same(builder, result);
}

#endregion

}
}
Loading