diff --git a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs index 744e38567f..3ef7a55a6b 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs @@ -9,6 +9,7 @@ namespace Microsoft.Identity.Client.OAuth2 internal static class OAuth2Parameter { public const string ResponseType = "response_type"; + public const string ResponseMode = "response_mode"; public const string GrantType = "grant_type"; public const string ClientId = "client_id"; public const string ClientSecret = "client_secret"; diff --git a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/AuthorizationResponse.cs b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/AuthorizationResponse.cs new file mode 100644 index 0000000000..9ce7f36a9e --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/AuthorizationResponse.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser +{ + /// + /// Result from intercepting an authorization response + /// + internal class AuthorizationResponse + { + public AuthorizationResponse(Uri requestUri, byte[] postData) + { + RequestUri = requestUri; + PostData = postData; + } + + public Uri RequestUri { get; set; } + public byte[] PostData { get; set; } + public bool IsFormPost => PostData != null && PostData.Length > 0; + } +} diff --git a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/DefaultOsBrowserWebUi.cs b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/DefaultOsBrowserWebUi.cs index 56b1fb1174..ffd08e0a3d 100644 --- a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/DefaultOsBrowserWebUi.cs +++ b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/DefaultOsBrowserWebUi.cs @@ -11,9 +11,11 @@ using System.Threading.Tasks; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Client.Platforms.Shared.DefaultOSBrowser; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; using Microsoft.Identity.Client.UI; +using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser { @@ -22,16 +24,20 @@ internal class DefaultOsBrowserWebUi : IWebUI internal const string DefaultSuccessHtml = @" Authentication Complete - Authentication complete. You can return to the application. Feel free to close this browser tab. +

Authentication complete.

+

You can return to the application. Please close this browser tab.

+

For your security: Do not share the contents of this page, the address bar, or take screenshots.

"; internal const string DefaultFailureHtml = @" Authentication Failed - Authentication failed. You can return to the application. Feel free to close this browser tab. -



- Error details: error {0} error_description: {1} +

Authentication failed.

+

You can return to the application. Please close this browser tab.

+

For your security: Do not share the contents of this page, the address bar, or take screenshots.

+
+

Error details: error {0} error_description: {1}

"; @@ -61,24 +67,48 @@ public async Task AcquireAuthorizationAsync( { try { - var authCodeUri = await InterceptAuthorizationUriAsync( + var authUriBuilder = new UriBuilder(authorizationUri); + + // Warn if response_mode was set to something other than form_post + if (authorizationUri.Query.Contains("response_mode=") && + !authorizationUri.Query.Contains("response_mode=form_post")) + { + _logger.Warning("[DefaultOsBrowser] The 'response_mode' parameter will be overridden to 'form_post' for better security."); + } + + authUriBuilder.AppendOrReplaceQueryParameter(OAuth2Parameter.ResponseMode, "form_post"); + authorizationUri = authUriBuilder.Uri; + + _logger.Info(() => $"[DefaultOsBrowser] Authorization URI with form_post: {authorizationUri.AbsoluteUri}"); + _logger.Verbose(() => $"[DefaultOsBrowser] Query string contains response_mode: {authorizationUri.Query.Contains("response_mode=form_post")}"); + + var authResponse = await InterceptAuthorizationUriAsync( authorizationUri, redirectUri, requestContext.ServiceBundle.Config.IsBrokerEnabled, cancellationToken) .ConfigureAwait(true); - if (!authCodeUri.Authority.Equals(redirectUri.Authority, StringComparison.OrdinalIgnoreCase) || - !authCodeUri.AbsolutePath.Equals(redirectUri.AbsolutePath)) + if (!authResponse.RequestUri.Authority.Equals(redirectUri.Authority, StringComparison.OrdinalIgnoreCase) || + !authResponse.RequestUri.AbsolutePath.Equals(redirectUri.AbsolutePath)) { throw new MsalClientException( MsalError.LoopbackResponseUriMismatch, MsalErrorMessage.RedirectUriMismatch( - authCodeUri.AbsolutePath, + authResponse.RequestUri.AbsolutePath, redirectUri.AbsolutePath)); } - - return AuthorizationResult.FromUri(authCodeUri.OriginalString); + if (authResponse.IsFormPost) + { + _logger.Info(() => "[DefaultOsBrowser] Processing form_post response securely from POST data"); + return AuthorizationResult.FromPostData(authResponse.PostData); + } + else + { + throw new MsalClientException( + MsalError.AuthenticationFailed, + "The authorization server did not honor response_mode=form_post"); + } } catch (System.Net.HttpListenerException) // sometimes this exception sneaks out (see issue 1773) { @@ -127,7 +157,7 @@ private static Uri FindFreeLocalhostRedirectUri(Uri redirectUri) } } - private async Task InterceptAuthorizationUriAsync( + private async Task InterceptAuthorizationUriAsync( Uri authorizationUri, Uri redirectUri, bool isBrokerConfigured, @@ -148,10 +178,21 @@ private async Task InterceptAuthorizationUriAsync( .ConfigureAwait(false); } - internal /* internal for testing only */ MessageAndHttpCode GetResponseMessage(Uri authCodeUri) + internal /* internal for testing only */ MessageAndHttpCode GetResponseMessage(AuthorizationResponse authResponse) { - // Parse the uri to understand if an error was returned. This is done just to show the user a nice error message in the browser. - var authorizationResult = AuthorizationResult.FromUri(authCodeUri.OriginalString); + // Parse the response to understand if an error was returned. This is done just to show the user a nice error message in the browser. + AuthorizationResult authorizationResult; + + if (authResponse.IsFormPost) + { + // For form_post, parse from POST data + authorizationResult = AuthorizationResult.FromPostData(authResponse.PostData); + } + else + { + // For GET/query string responses, parse from URI + authorizationResult = AuthorizationResult.FromUri(authResponse.RequestUri.OriginalString); + } if (!string.IsNullOrEmpty(authorizationResult.Error)) { diff --git a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/HttpListenerInterceptor.cs b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/HttpListenerInterceptor.cs index 7f074e835f..a9bd94f529 100644 --- a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/HttpListenerInterceptor.cs +++ b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/HttpListenerInterceptor.cs @@ -25,10 +25,10 @@ public HttpListenerInterceptor(ILoggerAdapter logger) _logger = logger; } - public async Task ListenToSingleRequestAndRespondAsync( + public async Task ListenToSingleRequestAndRespondAsync( int port, string path, - Func responseProducer, + Func responseProducer, CancellationToken cancellationToken) { TestBeforeTopLevelCall?.Invoke(); @@ -74,11 +74,14 @@ public async Task ListenToSingleRequestAndRespondAsync( cancellationToken.ThrowIfCancellationRequested(); - Respond(responseProducer, context); + // Get the authorization response - either from query string (GET) or POST body (form_post) + AuthorizationResponse authResponse = await GetAuthorizationResponseAsync(context).ConfigureAwait(false); + + Respond(responseProducer, context, authResponse); _logger.Verbose(()=>"HttpListner received a message on " + urlToListenTo); - // the request URL should now contain the auth code and pkce - return context.Request.Url; + // Return the authorization response + return authResponse; } } // If cancellation is requested before GetContextAsync is called, then either @@ -107,6 +110,38 @@ public async Task ListenToSingleRequestAndRespondAsync( } } + private async Task GetAuthorizationResponseAsync(HttpListenerContext context) + { + _logger.Info(() => $"[HttpListener] Received {context.Request.HttpMethod} request. HasEntityBody: {context.Request.HasEntityBody}"); + _logger.Verbose(() => $"[HttpListener] Request URL: {context.Request.Url}"); + + // With response_mode=form_post, we MUST receive a POST request for security + if (context.Request.HttpMethod == "POST" && context.Request.HasEntityBody) + { + _logger.Info(() => "[HttpListener] Processing POST request with entity body (form_post response)"); + + using (var memoryStream = new System.IO.MemoryStream()) + { + await context.Request.InputStream.CopyToAsync(memoryStream).ConfigureAwait(false); + byte[] postData = memoryStream.ToArray(); + + _logger.Info(() => $"[HttpListener] Received POST data with {postData.Length} bytes"); + _logger.Verbose(() => "[HttpListener] Successfully processed POST data - keeping it secure (not reconstructing as URI)"); + + return new AuthorizationResponse(context.Request.Url, postData); + } + } + + // Security: We requested form_post, so receiving GET with query params is a security violation + _logger.Error($"[HttpListener] Security violation: Expected POST request with form_post, but received {context.Request.HttpMethod}. " + + "The authorization server did not honor response_mode=form_post, which exposes the authorization code in the URL."); + + throw new MsalClientException( + MsalError.AuthenticationFailed, + $"Expected POST request for form_post response mode, but received {context.Request.HttpMethod}. " + + "This is a security issue as the authorization code is exposed in the URL query parameters and browser history."); + } + private static void TryStopListening(HttpListener httpListener) { try @@ -118,9 +153,9 @@ private static void TryStopListening(HttpListener httpListener) } } - private void Respond(Func responseProducer, HttpListenerContext context) + private void Respond(Func responseProducer, HttpListenerContext context, AuthorizationResponse authResponse) { - MessageAndHttpCode messageAndCode = responseProducer(context.Request.Url); + MessageAndHttpCode messageAndCode = responseProducer(authResponse); _logger.Info(() => "Processing a response message to the browser. HttpStatus:" + messageAndCode.HttpCode); switch (messageAndCode.HttpCode) diff --git a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/IUriInterceptor.cs b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/IUriInterceptor.cs index b8a8666902..eaf48c2914 100644 --- a/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/IUriInterceptor.cs +++ b/src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/IUriInterceptor.cs @@ -9,24 +9,25 @@ namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser { /// /// An abstraction over objects that are able to listen to localhost url (e.g. http://localhost:1234) - /// and to retrieve the whole url, including query params (e.g. http://localhost:1234?code=auth_code_from_aad) + /// and to retrieve the authorization response via GET (query params) or POST (form data) /// internal interface IUriInterceptor { /// - /// Listens to http://localhost:{port} and retrieve the entire url, including query params. Then - /// push back a response such as a display message or a redirect. + /// Listens to http://localhost:{port} and retrieve the authorization response. + /// For GET requests, the response is in query params. For POST (form_post), the response is in the body. + /// Then push back a response such as a display message or a redirect. /// /// Cancellation is very important as this is typically a long running unmonitored operation /// the port to listen to /// the path to listen in /// The message to be displayed, or url to be redirected to will be created by this callback /// Cancellation token - /// Full redirect uri - Task ListenToSingleRequestAndRespondAsync( + /// Authorization response containing either URI with query params or POST data + Task ListenToSingleRequestAndRespondAsync( int port, string path, - Func responseProducer, + Func responseProducer, CancellationToken cancellationToken); } } diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/Infrastructure/SeleniumWebUI.cs b/tests/Microsoft.Identity.Test.Integration.netcore/Infrastructure/SeleniumWebUI.cs index 2de4d3c6b3..5c1110bcb6 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/Infrastructure/SeleniumWebUI.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/Infrastructure/SeleniumWebUI.cs @@ -14,6 +14,7 @@ using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.Platforms.Shared.DefaultOSBrowser; using Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser; +using Microsoft.Identity.Client.Utils; using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using OpenQA.Selenium; @@ -101,6 +102,13 @@ private async Task SeleniumAcquireAuthAsync( Uri redirectUri, CancellationToken externalCancellationToken) { + // Add response_mode=form_post to match production behavior + var authUriBuilder = new UriBuilder(authorizationUri); + authUriBuilder.AppendOrReplaceQueryParameter("response_mode", "form_post"); + authorizationUri = authUriBuilder.Uri; + + _logger.Info($"[SeleniumWebUI] Authorization URI with form_post: {authorizationUri.AbsoluteUri}"); + using (var driver = InitDriverAndGoToUrl(authorizationUri.OriginalString)) { var listener = new HttpListenerInterceptor(_logger); @@ -113,15 +121,48 @@ private async Task SeleniumAcquireAuthAsync( innerSource.Token, externalCancellationToken); - Task listenForAuthCodeTask = listener.ListenToSingleRequestAndRespondAsync( + Task listenForAuthCodeTask = listener.ListenToSingleRequestAndRespondAsync( redirectUri.Port, redirectUri.AbsolutePath, - (uri) => + (authResponse) => { - authCodeUri = uri; + // With form_post, auth code is in POST body, not query params + // Reconstruct URI with query params for compatibility with ICustomWebUi interface + if (authResponse.IsFormPost) + { + // Security: Ensure no data in query string when using form_post + if (!string.IsNullOrEmpty(authResponse.RequestUri.Query) && + authResponse.RequestUri.Query != "?") + { + _logger.Error($"[SeleniumWebUI] Security violation: Received form_post response with query parameters. " + + $"Query: {authResponse.RequestUri.Query}"); + throw new InvalidOperationException( + "Data should only be in POST body."); + } + + // Convert POST data to query params for ICustomWebUi compatibility + string postDataString = System.Text.Encoding.UTF8.GetString(authResponse.PostData); + var uriBuilder = new UriBuilder(authResponse.RequestUri); + uriBuilder.Query = postDataString; + authCodeUri = uriBuilder.Uri; + + _logger.Info($"[SeleniumWebUI] Form_post response received. Converted POST data to URI for ICustomWebUi compatibility."); + } + else + { + // SECURITY FAILURE: We requested form_post but received GET with query params + // This means the authorization server did not honor response_mode=form_post + _logger.Error($"[SeleniumWebUI] SECURITY FAILURE: Requested form_post but received GET response. " + + $"The authorization server did not honor response_mode=form_post. " + + $"Auth code is exposed in URL query parameters."); + throw new InvalidOperationException( + "Security violation: Requested response_mode=form_post but received GET request with query parameters. " + + "The authorization code is exposed in the URL. " + + "The authorization server must honor response_mode=form_post for security."); + } _logger.Info("Auth code intercepted. Writing message back to browser"); - return GetMessageToShowInBroswerAfterAuth(uri); + return GetMessageToShowInBroswerAfterAuth(authCodeUri); }, tcpCancellationToken.Token); diff --git a/tests/Microsoft.Identity.Test.Unit/WebUITests/DefaultOsBrowserWebUiTests.cs b/tests/Microsoft.Identity.Test.Unit/WebUITests/DefaultOsBrowserWebUiTests.cs index f65a75fe4c..149fb48917 100644 --- a/tests/Microsoft.Identity.Test.Unit/WebUITests/DefaultOsBrowserWebUiTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/WebUITests/DefaultOsBrowserWebUiTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Globalization; using System.Net; using System.Net.Sockets; @@ -23,17 +24,17 @@ namespace Microsoft.Identity.Test.Unit.WebUITests { internal class TestTcpInterceptor : IUriInterceptor { - private readonly Uri _expectedUri; + private readonly AuthorizationResponse _expectedResponse; public Func ResponseProducer { get; } - public TestTcpInterceptor(Uri expectedUri) + public TestTcpInterceptor(Uri expectedUri, byte[] postData = null) { - _expectedUri = expectedUri; + _expectedResponse = new AuthorizationResponse(expectedUri, postData); } - public Task ListenToSingleRequestAndRespondAsync(int port, string path, Func responseProducer, CancellationToken cancellationToken) + public Task ListenToSingleRequestAndRespondAsync(int port, string path, Func responseProducer, CancellationToken cancellationToken) { - return Task.FromResult(_expectedUri); + return Task.FromResult(_expectedResponse); } } @@ -65,18 +66,64 @@ private DefaultOsBrowserWebUi CreateTestWebUI(SystemWebViewOptions options = nul } [TestMethod] - public async Task DefaultOsBrowserWebUi_HappyPath_Async() + public async Task DefaultOsBrowserWebUi_FormPost_HappyPath_Async() { + // Test with form_post (POST data) + var postData = System.Text.Encoding.UTF8.GetBytes( + "code=auth_code&state=901e7d87-6f49-4f9f-9fa7-e6b8c32d5b9595bc1797-dacc-4ff1-b9e9-0df81be286c7&session_state=test"); + var webUI = CreateTestWebUI(); - AuthorizationResult authorizationResult = await AcquireAuthCodeAsync(webUI) + AuthorizationResult authorizationResult = await AcquireAuthCodeAsync( + webUI, + postData: postData) .ConfigureAwait(false); // Assert Assert.AreEqual(AuthorizationStatus.Success, authorizationResult.Status); Assert.IsFalse(string.IsNullOrEmpty(authorizationResult.Code)); + Assert.AreEqual("auth_code", authorizationResult.Code); + + // Verify that response_mode=form_post was added to the authorization URI + await _platformProxy.Received(1).StartDefaultOsBrowserAsync( + Arg.Is(s => s.Contains("response_mode=form_post")), + Arg.Any()) + .ConfigureAwait(false); await _tcpInterceptor.Received(1).ListenToSingleRequestAndRespondAsync( - TestPort, "/", Arg.Any>(), CancellationToken.None).ConfigureAwait(false); + TestPort, "/", Arg.Any>(), CancellationToken.None).ConfigureAwait(false); + } + + [TestMethod] + public async Task DefaultOsBrowserWebUi_ResponseModeQuery_OverriddenToFormPost_Async() + { + // Arrange - authorization URI already has response_mode=query set by developer + string requestUriWithQueryMode = TestAuthorizationRequestUri + "&response_mode=query"; + var postData = System.Text.Encoding.UTF8.GetBytes( + "code=auth_code&state=901e7d87-6f49-4f9f-9fa7-e6b8c32d5b9595bc1797-dacc-4ff1-b9e9-0df81be286c7&session_state=test"); + + var webUI = CreateTestWebUI(); + + AuthorizationResult authorizationResult = await AcquireAuthCodeAsync( + webUI, + requestUri: requestUriWithQueryMode, + postData: postData) + .ConfigureAwait(false); + + Assert.AreEqual(AuthorizationStatus.Success, authorizationResult.Status); + Assert.AreEqual("auth_code", authorizationResult.Code); + + // Verify that response_mode=form_post overrode the query mode + await _platformProxy.Received(1).StartDefaultOsBrowserAsync( + Arg.Is(s => s.Contains("response_mode=form_post") && !s.Contains("response_mode=query")), + Arg.Any()) + .ConfigureAwait(false); + + // Verify warning was logged about the override + // Warning() extension method translates to Log(LogLevel.Warning, string.Empty, message) + _logger.Received(1).Log( + LogLevel.Warning, + string.Empty, + Arg.Is(s => s.Contains("response_mode") && s.Contains("overridden") && s.Contains("form_post"))); } [TestMethod] @@ -90,7 +137,7 @@ public async Task HttpListenerException_Cancellation_Async() _tcpInterceptor.When(x => x.ListenToSingleRequestAndRespondAsync( TestPort, "/", - Arg.Any>(), + Arg.Any>(), cts.Token)) .Do(_ => { @@ -124,13 +171,15 @@ public async Task DefaultOsBrowserWebUi_CustomBrowser_Async() var webUI = CreateTestWebUI(options); var requestContext = new RequestContext(TestCommon.CreateDefaultServiceBundle(), Guid.NewGuid(), null); var responseUri = new Uri(TestAuthorizationResponseUri); + var postData = System.Text.Encoding.UTF8.GetBytes("code=some_auth_code&state=some_state"); + var authResponse = new AuthorizationResponse(responseUri, postData); _tcpInterceptor.ListenToSingleRequestAndRespondAsync( TestPort, "/", - Arg.Any>(), + Arg.Any>(), CancellationToken.None) - .Returns(Task.FromResult(responseUri)); + .Returns(Task.FromResult(authResponse)); // Act AuthorizationResult authorizationResult = await webUI.AcquireAuthorizationAsync( @@ -144,7 +193,7 @@ await _platformProxy.DidNotReceiveWithAnyArgs().StartDefaultOsBrowserAsync(defau .ConfigureAwait(false); await _tcpInterceptor.Received(1).ListenToSingleRequestAndRespondAsync( - TestPort, "/", Arg.Any>(), CancellationToken.None).ConfigureAwait(false); + TestPort, "/", Arg.Any>(), CancellationToken.None).ConfigureAwait(false); Assert.IsTrue(customOpenBrowserCalled); } @@ -165,7 +214,8 @@ private async Task AcquireAuthCodeAsync( IWebUI webUI, string redirectUri = TestRedirectUri, string requestUri = TestAuthorizationRequestUri, - string responseUriString = TestAuthorizationResponseUri) + string responseUriString = TestAuthorizationResponseUri, + byte[] postData = null) { // Arrange var requestContext = new RequestContext(TestCommon.CreateDefaultServiceBundle(), Guid.NewGuid(), null); @@ -174,9 +224,9 @@ private async Task AcquireAuthCodeAsync( _tcpInterceptor.ListenToSingleRequestAndRespondAsync( TestPort, "/", - Arg.Any>(), + Arg.Any>(), CancellationToken.None) - .Returns(Task.FromResult(responseUri)); + .Returns(Task.FromResult(new AuthorizationResponse(responseUri, postData))); // Act AuthorizationResult authorizationResult = await webUI.AcquireAuthorizationAsync( @@ -186,7 +236,10 @@ private async Task AcquireAuthCodeAsync( CancellationToken.None).ConfigureAwait(false); // Assert that we opened the browser - await _platformProxy.Received(1).StartDefaultOsBrowserAsync(requestUri, requestContext.ServiceBundle.Config.IsBrokerEnabled) + // Verify response_mode=form_post is present (don't check for full requestUri to allow parameter replacement tests) + await _platformProxy.Received(1).StartDefaultOsBrowserAsync( + Arg.Is(s => s.Contains("response_mode=form_post")), + requestContext.ServiceBundle.Config.IsBrokerEnabled) .ConfigureAwait(false); return authorizationResult; @@ -298,7 +351,7 @@ private void ValidateResponse(SystemWebViewOptions options, bool successResponse new Uri(TestErrorAuthorizationResponseUri); // Act - MessageAndHttpCode messageAndCode = webUi.GetResponseMessage(successAuthCodeUri); + MessageAndHttpCode messageAndCode = webUi.GetResponseMessage(new AuthorizationResponse(successAuthCodeUri, null)); // Assert if (expectedMessage != null) diff --git a/tests/Microsoft.Identity.Test.Unit/WebUITests/HttpListenerInterceptorTests.cs b/tests/Microsoft.Identity.Test.Unit/WebUITests/HttpListenerInterceptorTests.cs index 48ddb7bd5b..5083ced1e1 100644 --- a/tests/Microsoft.Identity.Test.Unit/WebUITests/HttpListenerInterceptorTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/WebUITests/HttpListenerInterceptorTests.cs @@ -6,6 +6,7 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using Microsoft.Identity.Client; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Platforms.Shared.DefaultOSBrowser; using Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser; @@ -19,30 +20,47 @@ namespace Microsoft.Identity.Test.Unit.WebUITests public class HttpListenerInterceptorTests { [TestMethod] - public async Task HttpListenerCompletesAsync() + public async Task HttpListenerRejectsGetRequestAsync() { - HttpListenerInterceptor listenerInterceptor = new HttpListenerInterceptor( Substitute.For()); int port = FindFreeLocalhostPort(); // Start the listener in the background - Task listenTask = listenerInterceptor.ListenToSingleRequestAndRespondAsync( + Task listenTask = listenerInterceptor.ListenToSingleRequestAndRespondAsync( port, string.Empty, (_) => { return new MessageAndHttpCode(HttpStatusCode.OK, "OK"); }, CancellationToken.None); - // Issue an HTTP request on the main thread - await SendMessageToPortAsync(port, string.Empty).ConfigureAwait(false); + // Give listener more time to start accepting connections + await Task.Delay(500).ConfigureAwait(false); - // Wait for the listener to do its stuff - listenTask.Wait(2000 /* 2s timeout */); + // Issue an HTTP GET request (should be rejected for security) + // We don't care if the HTTP client throws - we care that the listener rejects it + try + { + await SendMessageToPortAsync(port, string.Empty).ConfigureAwait(false); + } + catch + { + // The HTTP client may throw if the listener closes the connection + // This is expected and fine - we'll verify the listener's behavior + } - // Assert - Assert.IsTrue(listenTask.IsCompleted); - Assert.AreEqual(GetLocalhostUriWithParams(port, string.Empty), listenTask.Result.ToString()); + // Wait for the listener to complete or fault (without throwing) + await Task.WhenAny(listenTask, Task.Delay(5000)).ConfigureAwait(false); + + // Assert - should throw security exception + Assert.IsTrue(listenTask.IsCompleted, "Listener task should complete within timeout"); + Assert.IsTrue(listenTask.IsFaulted, "GET request should cause the task to fault"); + Assert.IsNotNull(listenTask.Exception, "Exception should be captured"); + Assert.IsInstanceOfType(listenTask.Exception.InnerException, typeof(MsalClientException)); + + var msalEx = (MsalClientException)listenTask.Exception.InnerException; + Assert.AreEqual(MsalError.AuthenticationFailed, msalEx.ErrorCode); + Assert.IsTrue(msalEx.Message.Contains("Expected POST request"), "Error message should explain POST is required"); } [TestMethod] @@ -96,7 +114,7 @@ public async Task ValidateHttpListenerRedirectUriAsync() listenerInterceptor.TestBeforeStart = (url) => Assert.AreEqual($"http://localhost:{port}/TestPath/", url); // Start listener in the background - Task listenTask = listenerInterceptor.ListenToSingleRequestAndRespondAsync( + Task listenTask = listenerInterceptor.ListenToSingleRequestAndRespondAsync( port, "/TestPath/", (_) => new MessageAndHttpCode(HttpStatusCode.OK, "OK"), @@ -105,15 +123,16 @@ public async Task ValidateHttpListenerRedirectUriAsync() // Ensure the listener is bound before making the request await EnsureListenerIsReady(port, TimeSpan.FromSeconds(2)).ConfigureAwait(false); - // Issue an HTTP request - await SendMessageToPortAsync(port, "TestPath").ConfigureAwait(false); + // Issue an HTTP POST request (form_post requires POST) + await SendPostMessageToPortAsync(port, "TestPath", "code=test_code&state=test_state").ConfigureAwait(false); // Wait for listener to handle request with a timeout bool completed = (await Task.WhenAny(listenTask, Task.Delay(5000)).ConfigureAwait(false)) == listenTask; // Assert Assert.IsTrue(completed, "Listener did not complete within timeout."); - Assert.AreEqual(GetLocalhostUriWithParams(port, "TestPath"), listenTask.Result.ToString()); + Assert.IsTrue(listenTask.Result.RequestUri.ToString().StartsWith($"http://localhost:{port}/TestPath/"), + "Request URI should include the custom path"); } /// @@ -160,6 +179,45 @@ await AssertException.TaskThrowsAsync( .ConfigureAwait(false); } + [TestMethod] + public async Task HttpListenerHandlesPostDataAsync() + { + HttpListenerInterceptor listenerInterceptor = new HttpListenerInterceptor( + Substitute.For()); + + int port = FindFreeLocalhostPort(); + + // Start the listener in the background + Task listenTask = listenerInterceptor.ListenToSingleRequestAndRespondAsync( + port, + string.Empty, + (_) => { return new MessageAndHttpCode(HttpStatusCode.OK, "OK"); }, + CancellationToken.None); + + // Issue an HTTP POST request with form data (simulating form_post response mode) + await SendPostMessageToPortAsync(port, string.Empty, "code=auth_code_value&state=state_value").ConfigureAwait(false); + + // Wait for the listener to complete + listenTask.Wait(2000 /* 2s timeout */); + + // Assert + Assert.IsTrue(listenTask.IsCompleted); + Assert.IsNotNull(listenTask.Result); + Assert.IsTrue(listenTask.Result.IsFormPost, "Response should be identified as form_post"); + Assert.IsNotNull(listenTask.Result.PostData, "POST data should be captured"); + + // Verify the POST data contains the expected values + string postDataString = System.Text.Encoding.UTF8.GetString(listenTask.Result.PostData); + Assert.IsTrue(postDataString.Contains("code=auth_code_value")); + Assert.IsTrue(postDataString.Contains("state=state_value")); + + // Verify the request URI is clean (no query params) + Assert.AreEqual("/", listenTask.Result.RequestUri.AbsolutePath); + Assert.IsTrue(string.IsNullOrEmpty(listenTask.Result.RequestUri.Query) || + listenTask.Result.RequestUri.Query == "?", + "Request URI should not contain query parameters when using form_post"); + } + private async Task SendMessageToPortAsync(int port, string path) { using (HttpClient httpClient = new HttpClient()) @@ -168,6 +226,18 @@ private async Task SendMessageToPortAsync(int port, string path) } } + private async Task SendPostMessageToPortAsync(int port, string path, string postData) + { + using (HttpClient httpClient = new HttpClient()) + { + var content = new StringContent(postData, System.Text.Encoding.UTF8, "application/x-www-form-urlencoded"); + string uri = string.IsNullOrEmpty(path) + ? $"http://localhost:{port}/" + : $"http://localhost:{port}/{path}/"; + await httpClient.PostAsync(uri, content).ConfigureAwait(false); + } + } + private static string GetLocalhostUriWithParams(int port, string path) { if (string.IsNullOrEmpty(path))