Skip to content

Commit efffab8

Browse files
committed
user form_post response mode
1 parent 8f87b40 commit efffab8

File tree

7 files changed

+205
-46
lines changed

7 files changed

+205
-46
lines changed

src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace Microsoft.Identity.Client.OAuth2
99
internal static class OAuth2Parameter
1010
{
1111
public const string ResponseType = "response_type";
12+
public const string ResponseMode = "response_mode";
1213
public const string GrantType = "grant_type";
1314
public const string ClientId = "client_id";
1415
public const string ClientSecret = "client_secret";

src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/DefaultOsBrowserWebUi.cs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
using System.Threading.Tasks;
1212
using Microsoft.Identity.Client.Core;
1313
using Microsoft.Identity.Client.Internal;
14+
using Microsoft.Identity.Client.OAuth2;
1415
using Microsoft.Identity.Client.Platforms.Shared.DefaultOSBrowser;
1516
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
1617
using Microsoft.Identity.Client.UI;
18+
using Microsoft.Identity.Client.Utils;
1719

1820
namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser
1921
{
@@ -61,24 +63,43 @@ public async Task<AuthorizationResult> AcquireAuthorizationAsync(
6163
{
6264
try
6365
{
64-
var authCodeUri = await InterceptAuthorizationUriAsync(
66+
// Add response_mode=form_post for security (prevents auth code from appearing in browser history/logs)
67+
var authUriBuilder = new UriBuilder(authorizationUri);
68+
authUriBuilder.AppendOrReplaceQueryParameter(OAuth2Parameter.ResponseMode, "form_post");
69+
authorizationUri = authUriBuilder.Uri;
70+
71+
_logger.Info(() => $"[DefaultOsBrowser] Authorization URI with form_post: {authorizationUri.AbsoluteUri}");
72+
_logger.Verbose(() => $"[DefaultOsBrowser] Query string contains response_mode: {authorizationUri.Query.Contains("response_mode=form_post")}");
73+
74+
var authResponse = await InterceptAuthorizationUriAsync(
6575
authorizationUri,
6676
redirectUri,
6777
requestContext.ServiceBundle.Config.IsBrokerEnabled,
6878
cancellationToken)
6979
.ConfigureAwait(true);
7080

71-
if (!authCodeUri.Authority.Equals(redirectUri.Authority, StringComparison.OrdinalIgnoreCase) ||
72-
!authCodeUri.AbsolutePath.Equals(redirectUri.AbsolutePath))
81+
if (!authResponse.RequestUri.Authority.Equals(redirectUri.Authority, StringComparison.OrdinalIgnoreCase) ||
82+
!authResponse.RequestUri.AbsolutePath.Equals(redirectUri.AbsolutePath))
7383
{
7484
throw new MsalClientException(
7585
MsalError.LoopbackResponseUriMismatch,
7686
MsalErrorMessage.RedirectUriMismatch(
77-
authCodeUri.AbsolutePath,
87+
authResponse.RequestUri.AbsolutePath,
7888
redirectUri.AbsolutePath));
7989
}
8090

81-
return AuthorizationResult.FromUri(authCodeUri.OriginalString);
91+
// Use FromPostData for form_post responses (more secure - never constructs URI with auth code)
92+
// Use FromUri for legacy GET responses (query string)
93+
if (authResponse.IsFormPost)
94+
{
95+
_logger.Info(() => "[DefaultOsBrowser] Processing form_post response securely from POST data");
96+
return AuthorizationResult.FromPostData(authResponse.PostData);
97+
}
98+
else
99+
{
100+
_logger.Info(() => "[DefaultOsBrowser] Processing legacy GET response from query string");
101+
return AuthorizationResult.FromUri(authResponse.RequestUri.OriginalString);
102+
}
82103
}
83104
catch (System.Net.HttpListenerException) // sometimes this exception sneaks out (see issue 1773)
84105
{
@@ -127,7 +148,7 @@ private static Uri FindFreeLocalhostRedirectUri(Uri redirectUri)
127148
}
128149
}
129150

130-
private async Task<Uri> InterceptAuthorizationUriAsync(
151+
private async Task<AuthorizationResponse> InterceptAuthorizationUriAsync(
131152
Uri authorizationUri,
132153
Uri redirectUri,
133154
bool isBrokerConfigured,
@@ -148,10 +169,21 @@ private async Task<Uri> InterceptAuthorizationUriAsync(
148169
.ConfigureAwait(false);
149170
}
150171

151-
internal /* internal for testing only */ MessageAndHttpCode GetResponseMessage(Uri authCodeUri)
172+
internal /* internal for testing only */ MessageAndHttpCode GetResponseMessage(AuthorizationResponse authResponse)
152173
{
153-
// 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.
154-
var authorizationResult = AuthorizationResult.FromUri(authCodeUri.OriginalString);
174+
// 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.
175+
AuthorizationResult authorizationResult;
176+
177+
if (authResponse.IsFormPost)
178+
{
179+
// For form_post, parse from POST data
180+
authorizationResult = AuthorizationResult.FromPostData(authResponse.PostData);
181+
}
182+
else
183+
{
184+
// For GET/query string responses, parse from URI
185+
authorizationResult = AuthorizationResult.FromUri(authResponse.RequestUri.OriginalString);
186+
}
155187

156188
if (!string.IsNullOrEmpty(authorizationResult.Error))
157189
{

src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/HttpListenerInterceptor.cs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ public HttpListenerInterceptor(ILoggerAdapter logger)
2525
_logger = logger;
2626
}
2727

28-
public async Task<Uri> ListenToSingleRequestAndRespondAsync(
28+
public async Task<AuthorizationResponse> ListenToSingleRequestAndRespondAsync(
2929
int port,
3030
string path,
31-
Func<Uri, MessageAndHttpCode> responseProducer,
31+
Func<AuthorizationResponse, MessageAndHttpCode> responseProducer,
3232
CancellationToken cancellationToken)
3333
{
3434
TestBeforeTopLevelCall?.Invoke();
@@ -74,11 +74,14 @@ public async Task<Uri> ListenToSingleRequestAndRespondAsync(
7474

7575
cancellationToken.ThrowIfCancellationRequested();
7676

77-
Respond(responseProducer, context);
77+
// Get the authorization response - either from query string (GET) or POST body (form_post)
78+
AuthorizationResponse authResponse = await GetAuthorizationResponseAsync(context).ConfigureAwait(false);
79+
80+
Respond(responseProducer, context, authResponse);
7881
_logger.Verbose(()=>"HttpListner received a message on " + urlToListenTo);
7982

80-
// the request URL should now contain the auth code and pkce
81-
return context.Request.Url;
83+
// Return the authorization response
84+
return authResponse;
8285
}
8386
}
8487
// If cancellation is requested before GetContextAsync is called, then either
@@ -107,6 +110,39 @@ public async Task<Uri> ListenToSingleRequestAndRespondAsync(
107110
}
108111
}
109112

113+
private async Task<AuthorizationResponse> GetAuthorizationResponseAsync(HttpListenerContext context)
114+
{
115+
_logger.Info(() => $"[HttpListener] Received {context.Request.HttpMethod} request. HasEntityBody: {context.Request.HasEntityBody}");
116+
_logger.Verbose(() => $"[HttpListener] Request URL: {context.Request.Url}");
117+
118+
// With response_mode=form_post, we MUST receive a POST request for security
119+
if (context.Request.HttpMethod == "POST" && context.Request.HasEntityBody)
120+
{
121+
_logger.Info(() => "[HttpListener] Processing POST request with entity body (form_post response)");
122+
123+
using (var memoryStream = new System.IO.MemoryStream())
124+
{
125+
await context.Request.InputStream.CopyToAsync(memoryStream).ConfigureAwait(false);
126+
byte[] postData = memoryStream.ToArray();
127+
128+
_logger.Info(() => $"[HttpListener] Received POST data with {postData.Length} bytes");
129+
_logger.Verbose(() => "[HttpListener] Successfully processed POST data - keeping it secure (not reconstructing as URI)");
130+
131+
return new AuthorizationResponse(context.Request.Url, postData);
132+
}
133+
}
134+
135+
// Security: We requested form_post, so receiving GET with query params is a security violation
136+
_logger.Error($"[HttpListener] Security violation: Expected POST request with form_post, but received {context.Request.HttpMethod}. " +
137+
"The authorization server did not honor response_mode=form_post, which exposes the authorization code in the URL.");
138+
139+
throw new MsalClientException(
140+
MsalError.AuthenticationFailed,
141+
$"Expected POST request for form_post response mode, but received {context.Request.HttpMethod}. " +
142+
"This is a security issue as the authorization code would be exposed in browser history and logs. " +
143+
"Ensure the authorization server supports response_mode=form_post and the redirect URI is registered as a 'Web' platform.");
144+
}
145+
110146
private static void TryStopListening(HttpListener httpListener)
111147
{
112148
try
@@ -118,9 +154,9 @@ private static void TryStopListening(HttpListener httpListener)
118154
}
119155
}
120156

121-
private void Respond(Func<Uri, MessageAndHttpCode> responseProducer, HttpListenerContext context)
157+
private void Respond(Func<AuthorizationResponse, MessageAndHttpCode> responseProducer, HttpListenerContext context, AuthorizationResponse authResponse)
122158
{
123-
MessageAndHttpCode messageAndCode = responseProducer(context.Request.Url);
159+
MessageAndHttpCode messageAndCode = responseProducer(authResponse);
124160
_logger.Info(() => "Processing a response message to the browser. HttpStatus:" + messageAndCode.HttpCode);
125161

126162
switch (messageAndCode.HttpCode)

src/client/Microsoft.Identity.Client/Platforms/Features/DefaultOSBrowser/IUriInterceptor.cs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,43 @@
77

88
namespace Microsoft.Identity.Client.Platforms.Shared.Desktop.OsBrowser
99
{
10+
/// <summary>
11+
/// Result from intercepting an authorization response
12+
/// </summary>
13+
internal class AuthorizationResponse
14+
{
15+
public AuthorizationResponse(Uri requestUri, byte[] postData)
16+
{
17+
RequestUri = requestUri;
18+
PostData = postData;
19+
}
20+
21+
public Uri RequestUri { get; set; }
22+
public byte[] PostData { get; set; }
23+
public bool IsFormPost => PostData != null && PostData.Length > 0;
24+
}
25+
1026
/// <summary>
1127
/// An abstraction over objects that are able to listen to localhost url (e.g. http://localhost:1234)
12-
/// and to retrieve the whole url, including query params (e.g. http://localhost:1234?code=auth_code_from_aad)
28+
/// and to retrieve the authorization response via GET (query params) or POST (form data)
1329
/// </summary>
1430
internal interface IUriInterceptor
1531
{
1632
/// <summary>
17-
/// Listens to http://localhost:{port} and retrieve the entire url, including query params. Then
18-
/// push back a response such as a display message or a redirect.
33+
/// Listens to http://localhost:{port} and retrieve the authorization response.
34+
/// For GET requests, the response is in query params. For POST (form_post), the response is in the body.
35+
/// Then push back a response such as a display message or a redirect.
1936
/// </summary>
2037
/// <remarks>Cancellation is very important as this is typically a long running unmonitored operation</remarks>
2138
/// <param name="port">the port to listen to</param>
2239
/// <param name="path">the path to listen in</param>
2340
/// <param name="responseProducer">The message to be displayed, or url to be redirected to will be created by this callback</param>
2441
/// <param name="cancellationToken">Cancellation token</param>
25-
/// <returns>Full redirect uri</returns>
26-
Task<Uri> ListenToSingleRequestAndRespondAsync(
42+
/// <returns>Authorization response containing either URI with query params or POST data</returns>
43+
Task<AuthorizationResponse> ListenToSingleRequestAndRespondAsync(
2744
int port,
2845
string path,
29-
Func<Uri, MessageAndHttpCode> responseProducer,
46+
Func<AuthorizationResponse, MessageAndHttpCode> responseProducer,
3047
CancellationToken cancellationToken);
3148
}
3249
}

tests/Microsoft.Identity.Test.Integration.netcore/Infrastructure/SeleniumWebUI.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ private async Task<Uri> SeleniumAcquireAuthAsync(
113113
innerSource.Token,
114114
externalCancellationToken);
115115

116-
Task<Uri> listenForAuthCodeTask = listener.ListenToSingleRequestAndRespondAsync(
116+
Task<AuthorizationResponse> listenForAuthCodeTask = listener.ListenToSingleRequestAndRespondAsync(
117117
redirectUri.Port,
118118
redirectUri.AbsolutePath,
119119
(uri) =>

tests/Microsoft.Identity.Test.Unit/WebUITests/DefaultOsBrowserWebUiTests.cs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ namespace Microsoft.Identity.Test.Unit.WebUITests
2323
{
2424
internal class TestTcpInterceptor : IUriInterceptor
2525
{
26-
private readonly Uri _expectedUri;
26+
private readonly AuthorizationResponse _expectedResponse;
2727
public Func<Uri, string> ResponseProducer { get; }
2828

29-
public TestTcpInterceptor(Uri expectedUri)
29+
public TestTcpInterceptor(Uri expectedUri, byte[] postData = null)
3030
{
31-
_expectedUri = expectedUri;
31+
_expectedResponse = new AuthorizationResponse(expectedUri, postData);
3232
}
3333

34-
public Task<Uri> ListenToSingleRequestAndRespondAsync(int port, string path, Func<Uri, MessageAndHttpCode> responseProducer, CancellationToken cancellationToken)
34+
public Task<AuthorizationResponse> ListenToSingleRequestAndRespondAsync(int port, string path, Func<AuthorizationResponse, MessageAndHttpCode> responseProducer, CancellationToken cancellationToken)
3535
{
36-
return Task.FromResult(_expectedUri);
36+
return Task.FromResult(_expectedResponse);
3737
}
3838
}
3939

@@ -65,15 +65,28 @@ private DefaultOsBrowserWebUi CreateTestWebUI(SystemWebViewOptions options = nul
6565
}
6666

6767
[TestMethod]
68-
public async Task DefaultOsBrowserWebUi_HappyPath_Async()
68+
public async Task DefaultOsBrowserWebUi_FormPost_HappyPath_Async()
6969
{
70+
// Test with form_post (POST data)
71+
var postData = System.Text.Encoding.UTF8.GetBytes(
72+
"code=auth_code&state=901e7d87-6f49-4f9f-9fa7-e6b8c32d5b9595bc1797-dacc-4ff1-b9e9-0df81be286c7&session_state=test");
73+
7074
var webUI = CreateTestWebUI();
71-
AuthorizationResult authorizationResult = await AcquireAuthCodeAsync(webUI)
75+
AuthorizationResult authorizationResult = await AcquireAuthCodeAsync(
76+
webUI,
77+
postData: postData)
7278
.ConfigureAwait(false);
7379

7480
// Assert
7581
Assert.AreEqual(AuthorizationStatus.Success, authorizationResult.Status);
7682
Assert.IsFalse(string.IsNullOrEmpty(authorizationResult.Code));
83+
Assert.AreEqual("auth_code", authorizationResult.Code);
84+
85+
// Verify that response_mode=form_post was added to the authorization URI
86+
await _platformProxy.Received(1).StartDefaultOsBrowserAsync(
87+
Arg.Is<string>(s => s.Contains("response_mode=form_post")),
88+
Arg.Any<bool>())
89+
.ConfigureAwait(false);
7790

7891
await _tcpInterceptor.Received(1).ListenToSingleRequestAndRespondAsync(
7992
TestPort, "/", Arg.Any<Func<Uri, MessageAndHttpCode>>(), CancellationToken.None).ConfigureAwait(false);
@@ -124,13 +137,14 @@ public async Task DefaultOsBrowserWebUi_CustomBrowser_Async()
124137
var webUI = CreateTestWebUI(options);
125138
var requestContext = new RequestContext(TestCommon.CreateDefaultServiceBundle(), Guid.NewGuid(), null);
126139
var responseUri = new Uri(TestAuthorizationResponseUri);
140+
var authResponse = new AuthorizationResponse(responseUri, null);
127141

128142
_tcpInterceptor.ListenToSingleRequestAndRespondAsync(
129143
TestPort,
130144
"/",
131145
Arg.Any<Func<Uri, MessageAndHttpCode>>(),
132146
CancellationToken.None)
133-
.Returns(Task.FromResult(responseUri));
147+
.Returns(Task.FromResult(authResponse));
134148

135149
// Act
136150
AuthorizationResult authorizationResult = await webUI.AcquireAuthorizationAsync(
@@ -165,7 +179,8 @@ private async Task<AuthorizationResult> AcquireAuthCodeAsync(
165179
IWebUI webUI,
166180
string redirectUri = TestRedirectUri,
167181
string requestUri = TestAuthorizationRequestUri,
168-
string responseUriString = TestAuthorizationResponseUri)
182+
string responseUriString = TestAuthorizationResponseUri,
183+
byte[] postData = null)
169184
{
170185
// Arrange
171186
var requestContext = new RequestContext(TestCommon.CreateDefaultServiceBundle(), Guid.NewGuid(), null);
@@ -176,7 +191,7 @@ private async Task<AuthorizationResult> AcquireAuthCodeAsync(
176191
"/",
177192
Arg.Any<Func<Uri, MessageAndHttpCode>>(),
178193
CancellationToken.None)
179-
.Returns(Task.FromResult(responseUri));
194+
.Returns(Task.FromResult(new AuthorizationResponse(responseUri, postData)));
180195

181196
// Act
182197
AuthorizationResult authorizationResult = await webUI.AcquireAuthorizationAsync(
@@ -186,7 +201,9 @@ private async Task<AuthorizationResult> AcquireAuthCodeAsync(
186201
CancellationToken.None).ConfigureAwait(false);
187202

188203
// Assert that we opened the browser
189-
await _platformProxy.Received(1).StartDefaultOsBrowserAsync(requestUri, requestContext.ServiceBundle.Config.IsBrokerEnabled)
204+
await _platformProxy.Received(1).StartDefaultOsBrowserAsync(
205+
Arg.Is<string>(s => s.Contains(requestUri) && s.Contains("response_mode=form_post")),
206+
requestContext.ServiceBundle.Config.IsBrokerEnabled)
190207
.ConfigureAwait(false);
191208

192209
return authorizationResult;
@@ -298,7 +315,7 @@ private void ValidateResponse(SystemWebViewOptions options, bool successResponse
298315
new Uri(TestErrorAuthorizationResponseUri);
299316

300317
// Act
301-
MessageAndHttpCode messageAndCode = webUi.GetResponseMessage(successAuthCodeUri);
318+
MessageAndHttpCode messageAndCode = webUi.GetResponseMessage(new AuthorizationResponse(successAuthCodeUri, null));
302319

303320
// Assert
304321
if (expectedMessage != null)

0 commit comments

Comments
 (0)