Skip to content

Commit 60d3401

Browse files
ashok672rayluo
andauthored
Add form_post response mode support for system browser authentication (#868)
* Initial changes * Deprecate response_mode parameter, better messages and more robust test * Update test_response_mode.py * Update authcode.py * Update application.py * Fix test failure in state_mismatch test * Fix for review comments * Update authcode.py * Update test_response_mode.py * Refactor to specify response_mode=form_post in a better spot * Treat parameter-less HTTP GET as error, while still keeping the welcome page and abort feature --------- Co-authored-by: Ray Luo <rayluo@microsoft.com>
1 parent eac15a9 commit 60d3401

File tree

6 files changed

+275
-30
lines changed

6 files changed

+275
-30
lines changed

msal/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ def _main():
339339
logging.error("Invalid input: %s", e)
340340
except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow
341341
print("Aborted")
342+
except Exception as e:
343+
logging.error("Error: %s", e)
342344

343345
if __name__ == "__main__":
344346
_main()

msal/application.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ def initiate_auth_code_flow(
961961
962962
:param str response_mode:
963963
OPTIONAL. Specifies the method with which response parameters should be returned.
964-
The default value is equivalent to ``query``, which is still secure enough in MSAL Python
964+
The default value is equivalent to ``query``, which was still secure enough in MSAL Python
965965
(because MSAL Python does not transfer tokens via query parameter in the first place).
966966
For even better security, we recommend using the value ``form_post``.
967967
In "form_post" mode, response parameters
@@ -973,6 +973,11 @@ def initiate_auth_code_flow(
973973
`here <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>`
974974
and `here <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>`
975975
976+
.. note::
977+
You should configure your web framework to accept form_post responses instead of query responses.
978+
While this parameter still works, it will be removed in a future version.
979+
Using query-based response modes is less secure and should be avoided.
980+
976981
:return:
977982
The auth code flow. It is a dict in this form::
978983
@@ -991,6 +996,9 @@ def initiate_auth_code_flow(
991996
3. and then relay this dict and subsequent auth response to
992997
:func:`~acquire_token_by_auth_code_flow()`.
993998
"""
999+
# Note to maintainers: Do not emit warning for the use of response_mode here,
1000+
# because response_mode=form_post is still the recommended usage for MSAL Python 1.x.
1001+
# App developers making the right call shall not be disturbed by unactionable warnings.
9941002
client = _ClientWithCcsRoutingInfo(
9951003
{"authorization_endpoint": self.authority.authorization_endpoint},
9961004
self.client_id,

msal/oauth2cli/authcode.py

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
It optionally opens a browser window to guide a human user to manually login.
66
After obtaining an auth code, the web server will automatically shut down.
77
"""
8+
from collections import defaultdict
89
import logging
910
import os
1011
import socket
@@ -109,29 +110,57 @@ def _printify(text):
109110

110111
class _AuthCodeHandler(BaseHTTPRequestHandler):
111112
def do_GET(self):
113+
qs = parse_qs(urlparse(self.path).query)
114+
welcome_param = qs.get('welcome', [None])[0]
115+
error_param = qs.get('error', [None])[0]
116+
if welcome_param == 'true': # Useful in manual e2e tests
117+
self._send_full_response(self.server.welcome_page)
118+
elif error_param == 'abort': # Useful in manual e2e tests
119+
self._send_full_response("Authentication aborted", is_ok=False)
120+
elif qs:
121+
# GET request with auth code or error - reject for security (form_post only)
122+
self._send_full_response(
123+
"response_mode=query is not supported for authentication responses. "
124+
"This application operates in response_mode=form_post mode only.",
125+
is_ok=False)
126+
else:
127+
# IdP may have error scenarios that result in a parameter-less GET request
128+
self._send_full_response(
129+
"Authentication could not be completed. You can close this window and return to the application.",
130+
is_ok=False)
131+
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
132+
133+
def do_POST(self): # Handle form_post response where auth code is in body
112134
# For flexibility, we choose to not check self.path matching redirect_uri
113135
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
114-
qs = parse_qs(urlparse(self.path).query)
115-
if qs.get('code') or qs.get("error"): # So, it is an auth response
116-
auth_response = _qs2kv(qs)
117-
logger.debug("Got auth response: %s", auth_response)
118-
if self.server.auth_state and self.server.auth_state != auth_response.get("state"):
119-
# OAuth2 successful and error responses contain state when it was used
120-
# https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1
121-
self._send_full_response("State mismatch") # Possibly an attack
122-
else:
123-
template = (self.server.success_template
124-
if "code" in qs else self.server.error_template)
125-
if _is_html(template.template):
126-
safe_data = _escape(auth_response) # Foiling an XSS attack
127-
else:
128-
safe_data = auth_response
129-
self._send_full_response(template.safe_substitute(**safe_data))
130-
self.server.auth_response = auth_response # Set it now, after the response is likely sent
136+
content_length = int(self.headers.get('Content-Length', 0))
137+
post_data = self.rfile.read(content_length).decode('utf-8')
138+
qs = parse_qs(post_data)
139+
if qs.get('code') or qs.get('error'): # So, it is an auth response
140+
self._process_auth_response(_qs2kv(qs))
131141
else:
132-
self._send_full_response(self.server.welcome_page)
142+
self._send_full_response("Invalid POST request", is_ok=False)
133143
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
134144

145+
def _process_auth_response(self, auth_response):
146+
"""Process the auth response from either GET or POST request."""
147+
logger.debug("Got auth response: %s", auth_response)
148+
if self.server.auth_state and self.server.auth_state != auth_response.get("state"):
149+
# OAuth2 successful and error responses contain state when it was used
150+
# https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1
151+
self._send_full_response( # Possibly an attack
152+
"State mismatch. Waiting for next response... or you may abort.", is_ok=False)
153+
else:
154+
template = (self.server.success_template
155+
if "code" in auth_response else self.server.error_template)
156+
if _is_html(template.template):
157+
safe_data = _escape(auth_response) # Foiling an XSS attack
158+
else:
159+
safe_data = auth_response
160+
filled_data = defaultdict(str, safe_data) # So that missing keys will be empty string
161+
self._send_full_response(template.safe_substitute(**filled_data))
162+
self.server.auth_response = auth_response # Set it now, after the response is likely sent
163+
135164
def _send_full_response(self, body, is_ok=True):
136165
self.send_response(200 if is_ok else 400)
137166
content_type = 'text/html' if _is_html(body) else 'text/plain'
@@ -215,6 +244,7 @@ def get_auth_response(self, timeout=None, **kwargs):
215244
216245
:param str auth_uri:
217246
If provided, this function will try to open a local browser.
247+
Starting from 2026, the built-in http server will require response_mode=form_post.
218248
:param int timeout: In seconds. None means wait indefinitely.
219249
:param str state:
220250
You may provide the state you used in auth_uri,
@@ -284,13 +314,23 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
284314
auth_uri_callback=None,
285315
browser_name=None,
286316
):
287-
welcome_uri = "http://localhost:{p}".format(p=self.get_port())
288-
abort_uri = "{loc}?error=abort".format(loc=welcome_uri)
317+
netloc = "http://localhost:{p}".format(p=self.get_port())
318+
abort_uri = "{loc}?error=abort".format(loc=netloc)
289319
logger.debug("Abort by visit %s", abort_uri)
320+
321+
if auth_uri:
322+
# Note to maintainers:
323+
# Do not enforce response_mode=form_post by secretly hardcoding it here.
324+
# Just validate it here, so we won't surprise caller by changing their auth_uri behind the scene.
325+
params = parse_qs(urlparse(auth_uri).query)
326+
assert params.get('response_mode', [None])[0] == 'form_post', (
327+
"The built-in http server supports HTTP POST only. "
328+
"The auth_uri must be built with response_mode=form_post")
329+
290330
self._server.welcome_page = Template(welcome_template or "").safe_substitute(
291331
auth_uri=auth_uri, abort_uri=abort_uri)
292332
if auth_uri: # Now attempt to open a local browser to visit it
293-
_uri = welcome_uri if welcome_template else auth_uri
333+
_uri = (netloc + "?welcome=true") if welcome_template else auth_uri
294334
logger.info("Open a browser on this device to visit: %s" % _uri)
295335
browser_opened = False
296336
try:
@@ -316,10 +356,14 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
316356
else: # Then it is the auth_uri_callback()'s job to inform the user
317357
auth_uri_callback(_uri)
318358

359+
recommendation = "For your security: Do not share the contents of this page, the address bar, or take screenshots." # From MSRC
319360
self._server.success_template = Template(success_template or
320-
"Authentication completed. You can close this window now.")
361+
"Authentication complete. You can return to the application. Please close this browser tab.\n\n" + recommendation)
321362
self._server.error_template = Template(error_template or
322-
"Authentication failed. $error: $error_description. ($error_uri)")
363+
# Do NOT invent new placeholders in this template. Just use standard keys defined in OAuth2 RFC.
364+
# Otherwise there is no obvious canonical way for caller to know what placeholders are supported.
365+
# Besides, we have been using these standard keys for years. Changing now would break backward compatibility.
366+
"Authentication failed. $error: $error_description. ($error_uri).\n\n" + recommendation)
323367

324368
self._server.timeout = timeout # Otherwise its handle_timeout() won't work
325369
self._server.auth_response = {} # Shared with _AuthCodeHandler
@@ -371,7 +415,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
371415
print(json.dumps(receiver.get_auth_response(
372416
auth_uri=flow["auth_uri"],
373417
welcome_template=
374-
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a",
418+
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a>",
375419
error_template="<html>Oh no. $error</html>",
376420
success_template="Oh yeah. Got $code",
377421
timeout=args.timeout,

msal/oauth2cli/oauth2.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,12 +396,21 @@ def obtain_token_by_device_flow(self,
396396

397397
def _build_auth_request_uri(
398398
self,
399-
response_type, redirect_uri=None, scope=None, state=None, **kwargs):
399+
response_type,
400+
*,
401+
redirect_uri=None, scope=None, state=None, response_mode=None,
402+
**kwargs):
400403
if "authorization_endpoint" not in self.configuration:
401404
raise ValueError("authorization_endpoint not found in configuration")
402405
authorization_endpoint = self.configuration["authorization_endpoint"]
406+
if response_mode != 'form_post':
407+
warnings.warn(
408+
"response_mode='form_post' is recommended for better security. "
409+
"See https://www.rfc-editor.org/rfc/rfc9700.html#section-4.3.1"
410+
)
403411
params = self._build_auth_request_params(
404412
response_type, redirect_uri=redirect_uri, scope=scope, state=state,
413+
response_mode=response_mode,
405414
**kwargs)
406415
sep = '&' if '?' in authorization_endpoint else '?'
407416
return "%s%s%s" % (authorization_endpoint, sep, urlencode(params))
@@ -669,6 +678,7 @@ def _obtain_token_by_browser(
669678
flow = self.initiate_auth_code_flow(
670679
redirect_uri=redirect_uri,
671680
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
681+
response_mode='form_post', # The auth_code_receiver has been changed to require it
672682
**(auth_params or {}))
673683
auth_response = auth_code_receiver.get_auth_response(
674684
auth_uri=flow["auth_uri"],

tests/test_authcode.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,81 @@ def test_no_two_concurrent_receivers_can_listen_on_same_port(self):
2626
pass
2727

2828
def test_template_should_escape_input(self):
29+
"""Test that HTML in error response is properly escaped"""
2930
with AuthCodeReceiver() as receiver:
3031
receiver._scheduled_actions = [( # Injection happens here when the port is known
3132
1, # Delay it until the receiver is activated by get_auth_response()
3233
lambda: self.assertEqual(
33-
"<html>&lt;tag&gt;foo&lt;/tag&gt;</html>",
34-
requests.get("http://localhost:{}?error=<tag>foo</tag>".format(
35-
receiver.get_port())).text,
36-
"Unsafe data in HTML should be escaped",
34+
"<html>&lt;script&gt;alert(&#x27;xss&#x27;);&lt;/script&gt;</html>",
35+
requests.post(
36+
"http://localhost:{}".format(receiver.get_port()),
37+
data={"error": "<script>alert('xss');</script>"},
38+
).text,
3739
))]
3840
receiver.get_auth_response( # Starts server and hang until timeout
3941
timeout=3,
4042
error_template="<html>$error</html>",
4143
)
4244

45+
def test_get_request_with_auth_code_is_rejected(self):
46+
"""Test that GET request with auth code is rejected for security"""
47+
with AuthCodeReceiver() as receiver:
48+
test_state = "test_state_67890"
49+
receiver._scheduled_actions = [(
50+
1,
51+
lambda: self.assertEqual(400, requests.get(
52+
"http://localhost:{}".format(receiver.get_port()), params={
53+
"code": "test_auth_code_12345",
54+
"state": test_state
55+
}
56+
).status_code)
57+
)]
58+
result = receiver.get_auth_response(timeout=3, state=test_state)
59+
self.assertIsNone(result, "Should not receive auth response via GET")
60+
61+
def test_post_request_with_auth_code(self):
62+
"""Test that POST request with auth code is handled correctly (form_post response mode)"""
63+
with AuthCodeReceiver() as receiver:
64+
test_code = "test_auth_code_12345"
65+
test_state = "test_state_67890"
66+
receiver._scheduled_actions = [(
67+
1,
68+
lambda: requests.post(
69+
"http://localhost:{}".format(receiver.get_port()),
70+
data={"code": test_code, "state": test_state},
71+
)
72+
)]
73+
result = receiver.get_auth_response(timeout=3, state=test_state)
74+
self.assertIsNotNone(result, "Should receive auth response via POST")
75+
self.assertEqual(result.get("code"), test_code)
76+
self.assertEqual(result.get("state"), test_state)
77+
78+
def test_post_request_with_error(self):
79+
"""Test that POST request with error is handled correctly"""
80+
with AuthCodeReceiver() as receiver:
81+
test_error = "access_denied"
82+
test_error_description = "User denied access"
83+
receiver._scheduled_actions = [(
84+
1,
85+
lambda: requests.post(
86+
"http://localhost:{}".format(receiver.get_port()),
87+
data={"error": test_error, "error_description": test_error_description},
88+
)
89+
)]
90+
result = receiver.get_auth_response(timeout=3)
91+
self.assertIsNotNone(result, "Should receive auth response via POST")
92+
self.assertEqual(result.get("error"), test_error)
93+
self.assertEqual(result.get("error_description"), test_error_description)
94+
95+
def test_post_request_state_mismatch(self):
96+
"""Test that POST request with mismatched state is rejected"""
97+
with AuthCodeReceiver() as receiver:
98+
receiver._scheduled_actions = [(
99+
1,
100+
lambda: requests.post(
101+
"http://localhost:{}".format(receiver.get_port()),
102+
data={"code": "test_code", "state": "wrong_state"},
103+
)
104+
)]
105+
result = receiver.get_auth_response(timeout=3, state="expected_state")
106+
self.assertIsNone(result, "Should not receive auth response due to state mismatch")

0 commit comments

Comments
 (0)