diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 429eda0ee..279fdef8f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -429,6 +429,8 @@ public async Task GetAuthenticationResultForUserAsync( await addInOptions.InvokeOnBeforeTokenAcquisitionForTestUserAsync(builder, tokenAcquisitionOptions, user!).ConfigureAwait(false); } + builder.WithSendX5C(mergedOptions.SendX5C); + // Pass the token acquisition options to the builder if (tokenAcquisitionOptions != null) { diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index c9a515a8e..0e0f1efd5 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -188,5 +188,200 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( Assert.StartsWith(IDWebErrorMessage.MissingIdentityConfiguration, exception.Message, System.StringComparison.Ordinal); } + + /// + /// 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. + /// + [Fact] + public async Task RopcFlow_WithSendX5CTrue_IncludesX5CInClientAssertion() + { + // Arrange + var factory = InitTokenAcquirerFactoryForRopcWithCertificate(sendX5C: true); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + var mockHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + mockHttpClient!.AddMockHandler(mockHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService(); + + // Create claims principal with username and password for pure ROPC flow + var claims = new List + { + 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); + } + } + + /// + /// 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. + /// + [Fact] + public async Task RopcFlow_WithSendX5CFalse_DoesNotIncludeX5CInClientAssertion() + { + // Arrange + var factory = InitTokenAcquirerFactoryForRopcWithCertificate(sendX5C: false); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + var mockHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + mockHttpClient!.AddMockHandler(mockHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService(); + + // Create claims principal with username and password + var claims = new List + { + 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); + } + } + + /// + /// Extracts the client_assertion parameter from HTTP POST data. + /// + /// The HTTP POST data dictionary. + /// The client_assertion JWT string, or null if not present. + private static string? GetClientAssertionFromPostData(Dictionary postData) + { + return postData.ContainsKey("client_assertion") ? postData["client_assertion"] : null; + } + + /// + /// Decodes the header portion of a JWT (JSON Web Token). + /// Converts base64url encoding to standard base64, then decodes to UTF-8 string. + /// + /// The complete JWT string in format: header.payload.signature + /// The decoded JWT header as a JSON string. + 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(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(mockHttpFactory); + + return tokenAcquirerFactory; + } + + /// + /// Creates a minimal self-signed certificate for testing purposes. + /// In unit tests, the mock HTTP handlers don't actually validate the certificate. + /// + 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; + } } }