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))