Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
efffab8
user form_post response mode
ashok672 Dec 22, 2025
a172dd0
Update DefaultOsBrowserWebUi.cs
ashok672 Dec 22, 2025
0331d2d
Update DefaultOsBrowserWebUi.cs
ashok672 Dec 22, 2025
d8e1a6a
Add warning log statement and add a test for override behavior
ashok672 Jan 7, 2026
a93b84b
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 7, 2026
6dffceb
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 7, 2026
455291b
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 13, 2026
70857ba
Fix test failures
ashok672 Jan 13, 2026
5209761
Merge branch 'asram/systembrowser_use_form_post_responsemode' of http…
ashok672 Jan 13, 2026
646ddcc
Update DefaultOsBrowserWebUiTests.cs
ashok672 Jan 13, 2026
03037b2
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
gladjohn Jan 13, 2026
3f6248a
Fix test failures
ashok672 Jan 14, 2026
24d6748
Merge branch 'asram/systembrowser_use_form_post_responsemode' of http…
ashok672 Jan 14, 2026
4010bba
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 14, 2026
07bc941
Update DefaultOsBrowserWebUiTests.cs
ashok672 Jan 14, 2026
48447d2
Merge branch 'asram/systembrowser_use_form_post_responsemode' of http…
ashok672 Jan 14, 2026
55d88c0
Update HttpListenerInterceptorTests.cs
ashok672 Jan 14, 2026
68bc874
Fix test failure
ashok672 Jan 14, 2026
bfdac8a
Update SeleniumWebUI.cs
ashok672 Jan 15, 2026
19a6ec5
Update SeleniumWebUI.cs
ashok672 Jan 15, 2026
de9bd2d
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 15, 2026
bf5b6e2
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 21, 2026
9b7c191
Move AuthorizationReponse class to a seprate file.
ashok672 Jan 23, 2026
80df4a3
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 23, 2026
9e62583
Merge branch 'main' into asram/systembrowser_use_form_post_responsemode
ashok672 Jan 27, 2026
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 @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Result from intercepting an authorization response
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -22,16 +24,20 @@ internal class DefaultOsBrowserWebUi : IWebUI
internal const string DefaultSuccessHtml = @"<html>
<head><title>Authentication Complete</title></head>
<body>
Authentication complete. You can return to the application. Feel free to close this browser tab.
<h3>Authentication complete.</h3>
<p>You can return to the application. Please close this browser tab.</p>
<p><strong>For your security:</strong> Do not share the contents of this page, the address bar, or take screenshots.</p>
</body>
</html>";

internal const string DefaultFailureHtml = @"<html>
<head><title>Authentication Failed</title></head>
<body>
Authentication failed. You can return to the application. Feel free to close this browser tab.
</br></br></br></br>
Error details: error {0} error_description: {1}
<h3>Authentication failed.</h3>
<p>You can return to the application. Please close this browser tab.</p>
<p><strong>For your security:</strong> Do not share the contents of this page, the address bar, or take screenshots.</p>
</br>
<p>Error details: error {0} error_description: {1}</p>
</body>
</html>";

Expand Down Expand Up @@ -61,24 +67,48 @@ public async Task<AuthorizationResult> 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)
{
Expand Down Expand Up @@ -127,7 +157,7 @@ private static Uri FindFreeLocalhostRedirectUri(Uri redirectUri)
}
}

private async Task<Uri> InterceptAuthorizationUriAsync(
private async Task<AuthorizationResponse> InterceptAuthorizationUriAsync(
Uri authorizationUri,
Uri redirectUri,
bool isBrokerConfigured,
Expand All @@ -148,10 +178,21 @@ private async Task<Uri> 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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ public HttpListenerInterceptor(ILoggerAdapter logger)
_logger = logger;
}

public async Task<Uri> ListenToSingleRequestAndRespondAsync(
public async Task<AuthorizationResponse> ListenToSingleRequestAndRespondAsync(
int port,
string path,
Func<Uri, MessageAndHttpCode> responseProducer,
Func<AuthorizationResponse, MessageAndHttpCode> responseProducer,
CancellationToken cancellationToken)
{
TestBeforeTopLevelCall?.Invoke();
Expand Down Expand Up @@ -74,11 +74,14 @@ public async Task<Uri> 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
Expand Down Expand Up @@ -107,6 +110,38 @@ public async Task<Uri> ListenToSingleRequestAndRespondAsync(
}
}

private async Task<AuthorizationResponse> 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
Expand All @@ -118,9 +153,9 @@ private static void TryStopListening(HttpListener httpListener)
}
}

private void Respond(Func<Uri, MessageAndHttpCode> responseProducer, HttpListenerContext context)
private void Respond(Func<AuthorizationResponse, MessageAndHttpCode> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,25 @@ namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser
{
/// <summary>
/// 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)
/// </summary>
internal interface IUriInterceptor
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>Cancellation is very important as this is typically a long running unmonitored operation</remarks>
/// <param name="port">the port to listen to</param>
/// <param name="path">the path to listen in</param>
/// <param name="responseProducer">The message to be displayed, or url to be redirected to will be created by this callback</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Full redirect uri</returns>
Task<Uri> ListenToSingleRequestAndRespondAsync(
/// <returns>Authorization response containing either URI with query params or POST data</returns>
Task<AuthorizationResponse> ListenToSingleRequestAndRespondAsync(
int port,
string path,
Func<Uri, MessageAndHttpCode> responseProducer,
Func<AuthorizationResponse, MessageAndHttpCode> responseProducer,
CancellationToken cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +102,13 @@ private async Task<Uri> 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);
Expand All @@ -113,15 +121,48 @@ private async Task<Uri> SeleniumAcquireAuthAsync(
innerSource.Token,
externalCancellationToken);

Task<Uri> listenForAuthCodeTask = listener.ListenToSingleRequestAndRespondAsync(
Task<AuthorizationResponse> 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);

Expand Down
Loading