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
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ public async Task<AuthenticationResult> GetAuthenticationResultForUserAsync(
await addInOptions.InvokeOnBeforeTokenAcquisitionForTestUserAsync(builder, tokenAcquisitionOptions, user!).ConfigureAwait(false);
}

builder.WithSendX5C(mergedOptions.SendX5C);

// Pass the token acquisition options to the builder
if (tokenAcquisitionOptions != null)
{
Expand Down
195 changes: 195 additions & 0 deletions tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,200 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(

Assert.StartsWith(IDWebErrorMessage.MissingIdentityConfiguration, exception.Message, System.StringComparison.Ordinal);
}

/// <summary>
/// Tests that SendX5C=true results in x5c claim being included in the client assertion.
/// This test examines the actual HTTP request to verify x5c presence in the JWT header.
/// </summary>
[Fact]
public async Task RopcFlow_WithSendX5CTrue_IncludesX5CInClientAssertion()
{
// Arrange
var factory = InitTokenAcquirerFactoryForRopcWithCertificate(sendX5C: true);
IServiceProvider serviceProvider = factory.Build();

var mockHttpClient = serviceProvider.GetRequiredService<IMsalHttpClientFactory>() as MockHttpClientFactory;

var mockHandler = MockHttpCreator.CreateClientCredentialTokenHandler();
mockHttpClient!.AddMockHandler(mockHandler);

IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();

// Create claims principal with username and password for pure ROPC flow
var claims = new List<System.Security.Claims.Claim>
{
new System.Security.Claims.Claim(ClaimConstants.Username, "testuser@contoso.com"),
new System.Security.Claims.Claim(ClaimConstants.Password, "testpassword123")
};
var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal(
new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity(claims));

// Act
string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync(
new[] { "https://graph.microsoft.com/.default" },
authorizationHeaderProviderOptions: null,
claimsPrincipal: claimsPrincipal);

// Assert
Assert.NotNull(result);
Assert.Equal("Bearer header.payload.signature", result);

// Verify the request was made
Assert.NotNull(mockHandler.ActualRequestMessage);
Assert.NotNull(mockHandler.ActualRequestPostData);

// Verify it's ROPC flow
Assert.True(mockHandler.ActualRequestPostData.ContainsKey("grant_type"));
Assert.Equal("password", mockHandler.ActualRequestPostData["grant_type"]);

// Verify x5c is present in client_assertion JWT header
string? clientAssertion = GetClientAssertionFromPostData(mockHandler.ActualRequestPostData);
if (clientAssertion != null)
{
string jwtHeader = DecodeJwtHeader(clientAssertion);
// With SendX5C=true, the header should contain "x5c" claim
Assert.Contains("\"x5c\"", jwtHeader, StringComparison.Ordinal);
}
}

/// <summary>
/// Tests that SendX5C=false results in NO x5c claim in the client assertion.
/// This verifies that the x5c certificate chain is excluded when SendX5C=false.
/// </summary>
[Fact]
public async Task RopcFlow_WithSendX5CFalse_DoesNotIncludeX5CInClientAssertion()
{
// Arrange
var factory = InitTokenAcquirerFactoryForRopcWithCertificate(sendX5C: false);
IServiceProvider serviceProvider = factory.Build();

var mockHttpClient = serviceProvider.GetRequiredService<IMsalHttpClientFactory>() as MockHttpClientFactory;

var mockHandler = MockHttpCreator.CreateClientCredentialTokenHandler();
mockHttpClient!.AddMockHandler(mockHandler);

IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();

// Create claims principal with username and password
var claims = new List<System.Security.Claims.Claim>
{
new System.Security.Claims.Claim(ClaimConstants.Username, "user@contoso.com"),
new System.Security.Claims.Claim(ClaimConstants.Password, "password123")
};
var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal(
new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity(claims));

// Act
string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync(
new[] { "https://graph.microsoft.com/.default" },
authorizationHeaderProviderOptions: null,
claimsPrincipal: claimsPrincipal);

// Assert
Assert.NotNull(result);
Assert.NotNull(mockHandler.ActualRequestMessage);
Assert.NotNull(mockHandler.ActualRequestPostData);

// Verify it's ROPC flow
Assert.True(mockHandler.ActualRequestPostData.ContainsKey("grant_type"));
Assert.Equal("password", mockHandler.ActualRequestPostData["grant_type"]);

// Verify x5c is NOT present in client_assertion JWT header
string? clientAssertion = GetClientAssertionFromPostData(mockHandler.ActualRequestPostData);
if (clientAssertion != null)
{
string jwtHeader = DecodeJwtHeader(clientAssertion);
// With SendX5C=false, the header should NOT contain "x5c" claim
Assert.DoesNotContain("\"x5c\"", jwtHeader, StringComparison.Ordinal);
}
}

Copy link
Contributor

@JoshLozensky JoshLozensky Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are any of these helpers generic enough to add to the Tests.Common project as TestHelpers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are very specific to validating that the x5c is propagated to MSAL and included in the request to ests.

/// <summary>
/// Extracts the client_assertion parameter from HTTP POST data.
/// </summary>
/// <param name="postData">The HTTP POST data dictionary.</param>
/// <returns>The client_assertion JWT string, or null if not present.</returns>
private static string? GetClientAssertionFromPostData(Dictionary<string, string> postData)
{
return postData.ContainsKey("client_assertion") ? postData["client_assertion"] : null;
}

/// <summary>
/// Decodes the header portion of a JWT (JSON Web Token).
/// Converts base64url encoding to standard base64, then decodes to UTF-8 string.
/// </summary>
/// <param name="jwt">The complete JWT string in format: header.payload.signature</param>
/// <returns>The decoded JWT header as a JSON string.</returns>
private static string DecodeJwtHeader(string jwt)
{
// Split JWT into parts (header.payload.signature)
var parts = jwt.Split('.');
if (parts.Length < 2)
{
return string.Empty;
}

// Convert base64url to base64
string base64 = parts[0].Replace('-', '+').Replace('_', '/');

// Add padding if needed
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}

// Decode base64 to bytes, then to UTF-8 string
return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64));
}

private TokenAcquirerFactory InitTokenAcquirerFactoryForRopcWithCertificate(bool sendX5C)
{
TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest();
TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance();

var mockHttpFactory = new MockHttpClientFactory();

tokenAcquirerFactory.Services.Configure<MicrosoftIdentityApplicationOptions>(options =>
{
options.Instance = "https://login.microsoftonline.com/";
options.TenantId = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca";
options.ClientId = "idu773ld-e38d-jud3-45lk-d1b09a74a8ca";
options.SendX5C = sendX5C; // Set the SendX5C flag

// SendX5C is only meaningful with certificate credentials
// Certificate is used for CLIENT authentication, username/password for USER authentication (ROPC)
options.ClientCredentials = [
CertificateDescription.FromCertificate(CreateTestCertificate())
];
});

// Add MockedHttpClientFactory
tokenAcquirerFactory.Services.AddSingleton<IMsalHttpClientFactory>(mockHttpFactory);

return tokenAcquirerFactory;
}

/// <summary>
/// Creates a minimal self-signed certificate for testing purposes.
/// In unit tests, the mock HTTP handlers don't actually validate the certificate.
/// </summary>
private static System.Security.Cryptography.X509Certificates.X509Certificate2 CreateTestCertificate()
{
// Create a minimal self-signed certificate for testing
// The certificate details don't matter for unit tests as HTTP calls are mocked
using var rsa = System.Security.Cryptography.RSA.Create(2048);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=TestCertificate",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);

var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(365));

return certificate;
}
}
}
Loading