Skip to content

Commit c4377da

Browse files
committed
Fix for review comments
1 parent 00a4bc0 commit c4377da

File tree

5 files changed

+71
-58
lines changed

5 files changed

+71
-58
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: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -965,8 +965,14 @@ def initiate_auth_code_flow(
965965
:param str response_mode:
966966
.. deprecated::
967967
This parameter is deprecated and will be removed in a future version.
968-
The response_mode is always set to ``form_post`` for security reasons,
969-
regardless of the value provided. Do not use this parameter.
968+
969+
**For PublicClientApplication**: response_mode is automatically set to
970+
``form_post`` for security reasons. This parameter is ignored.
971+
972+
**For ConfidentialClientApplication**: You should configure your web
973+
framework to accept form_post responses instead of query responses.
974+
While this parameter still works, it will be removed in a future version.
975+
Using query-based response modes is less secure and should be avoided.
970976
971977
:return:
972978
The auth code flow. It is a dict in this form::
@@ -990,7 +996,9 @@ def initiate_auth_code_flow(
990996
import warnings
991997
warnings.warn(
992998
"The 'response_mode' parameter is deprecated and will be removed in a future version. "
993-
"Response mode is always 'form_post' for security reasons.",
999+
"For public clients, response_mode is automatically set to 'form_post'. "
1000+
"For confidential clients, configure your web framework to use 'form_post'. "
1001+
"Query-based response modes are less secure.",
9941002
DeprecationWarning,
9951003
stacklevel=2)
9961004
client = _ClientWithCcsRoutingInfo(

msal/oauth2cli/authcode.py

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -111,52 +111,33 @@ class _AuthCodeHandler(BaseHTTPRequestHandler):
111111
def do_GET(self):
112112
# For flexibility, we choose to not check self.path matching redirect_uri
113113
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
114-
parsed_url = urlparse(self.path)
115-
qs = parse_qs(parsed_url.query)
116114

117-
if qs.get('code'): # Auth code via GET is a security risk - reject it
118-
logger.error("Received auth code via query string (GET request). "
119-
"This is a security risk. Only form_post (POST) is supported.")
115+
# Check if this is a blank redirect (eSTS error flow where user clicked OK)
116+
qs = parse_qs(urlparse(self.path).query)
117+
if not qs or (not qs.get('code') and not qs.get('error')):
118+
# Blank redirect from eSTS error - show generic error and mark done
120119
self._send_full_response(
121-
"Authentication method not supported. "
122-
"The application requires response_mode=form_post for security. "
123-
"Please ensure your application registration uses form_post response mode.",
124-
is_ok=False)
125-
elif qs.get("error"): # Errors can come via GET - process them
126-
auth_response = _qs2kv(qs)
127-
self._process_auth_response(auth_response)
128-
elif not qs and parsed_url.path == '/':
129-
# GET request to root with no query params - this is likely from clicking OK on eSTS error
130-
# Treat it as an error since success would come via POST
131-
logger.warning("Received GET request to root path with no query parameters - treating as authentication error")
132-
auth_response = {
133-
"error": "authentication_failed",
134-
"error_description": "Please close this window and try again."
135-
}
136-
# Include the original state so state validation doesn't fail
137-
if self.server.auth_state:
138-
auth_response["state"] = self.server.auth_state
139-
self._process_auth_response(auth_response)
120+
"Authentication could not be completed. "
121+
"You can close this window and return to the application.")
122+
self.server.done = True
140123
else:
124+
# GET request with parameters (shouldn't happen with form_post, but handle gracefully)
141125
self._send_full_response(self.server.welcome_page)
142126
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
143127

144128
def do_POST(self):
145129
# Handle form_post response mode where auth code is sent via POST body
146130
content_length = int(self.headers.get('Content-Length', 0))
147131
post_data = self.rfile.read(content_length).decode('utf-8')
148-
try:
149-
from urllib.parse import parse_qs as parse_qs_post
150-
except ImportError:
151-
from urlparse import parse_qs as parse_qs_post
152132

153-
qs = parse_qs_post(post_data)
133+
qs = parse_qs(post_data)
154134
if qs.get('code') or qs.get('error'): # So, it is an auth response
155135
auth_response = _qs2kv(qs)
156136
logger.debug("Got auth response via POST: %s", auth_response)
157137
self._process_auth_response(auth_response)
158138
else:
159139
self._send_full_response("Invalid POST request", is_ok=False)
140+
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
160141

161142
def _process_auth_response(self, auth_response):
162143
"""Process the auth response from either GET or POST request."""
@@ -177,7 +158,15 @@ def _process_auth_response(self, auth_response):
177158
# to avoid showing literal placeholder text like "$error_description"
178159
safe_data.setdefault("error", "")
179160
safe_data.setdefault("error_description", "")
180-
safe_data.setdefault("error_uri", "")
161+
# Format error message nicely: include ": description." only if description exists
162+
if "code" not in auth_response: # This is an error response
163+
error_desc = auth_response.get("error_description", "").strip()
164+
if error_desc:
165+
safe_data["error_message"] = f"{safe_data['error']}: {error_desc}."
166+
else:
167+
safe_data["error_message"] = safe_data["error"]
168+
else:
169+
safe_data["error_message"] = ""
181170
self._send_full_response(template.safe_substitute(**safe_data))
182171
self.server.auth_response = auth_response # Set it now, after the response is likely sent
183172

@@ -336,6 +325,15 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
336325
welcome_uri = "http://localhost:{p}".format(p=self.get_port())
337326
abort_uri = "{loc}?error=abort".format(loc=welcome_uri)
338327
logger.debug("Abort by visit %s", abort_uri)
328+
329+
# Enforce response_mode=form_post for security
330+
if auth_uri:
331+
parsed = urlparse(auth_uri)
332+
params = parse_qs(parsed.query)
333+
params['response_mode'] = ['form_post'] # Enforce form_post
334+
new_query = urlencode(params, doseq=True)
335+
auth_uri = parsed._replace(query=new_query).geturl()
336+
339337
self._server.welcome_page = Template(welcome_template or "").safe_substitute(
340338
auth_uri=auth_uri, abort_uri=abort_uri)
341339
if auth_uri: # Now attempt to open a local browser to visit it
@@ -369,7 +367,7 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
369367
"Authentication complete. You can return to the application. Please close this browser tab.\n\n"
370368
"For your security: Do not share the contents of this page, the address bar, or take screenshots.")
371369
self._server.error_template = Template(error_template or
372-
"Authentication failed. $error: $error_description.\n\n"
370+
"Authentication failed. $error_message\n\n"
373371
"For your security: Do not share the contents of this page, the address bar, or take screenshots.")
374372

375373
self._server.timeout = timeout # Otherwise its handle_timeout() won't work

msal/oauth2cli/oauth2.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,22 @@ def _build_auth_request_params(self, response_type, **kwargs):
176176
response_type = self._stringify(response_type)
177177

178178
params = {'client_id': self.client_id, 'response_type': response_type}
179-
# Strictly enforce form_post for security - query string is not allowed
180-
params['response_mode'] = 'form_post'
181-
if 'response_mode' in kwargs and kwargs['response_mode'] != 'form_post':
179+
params.update(kwargs)
180+
if 'response_mode' in params:
182181
import warnings
183182
warnings.warn(
184-
"The 'response_mode' parameter will be overridden to 'form_post' for better security.",
185-
UserWarning)
186-
params.update({k: v for k, v in kwargs.items() if k != 'response_mode'}) # Exclude response_mode from kwargs
183+
"The 'response_mode' parameter is deprecated and will be removed in a future version. "
184+
"For public clients, response_mode is automatically set to 'form_post'. "
185+
"For confidential clients, configure your web framework to use 'form_post'. "
186+
"Query-based response modes are less secure.",
187+
DeprecationWarning,
188+
stacklevel=3)
189+
if params['response_mode'] != 'form_post':
190+
warnings.warn(
191+
"response_mode='form_post' is recommended for better security. "
192+
"See https://tools.ietf.org/html/rfc6749#section-4.1.2",
193+
UserWarning,
194+
stacklevel=3)
187195
params = {k: v for k, v in params.items() if v is not None} # clean up
188196
if params.get('scope'):
189197
params['scope'] = self._stringify(params['scope'])
@@ -474,13 +482,6 @@ def initiate_auth_code_flow(
474482
3. and then relay this dict and subsequent auth response to
475483
:func:`~obtain_token_by_auth_code_flow()`.
476484
"""
477-
if "response_mode" in kwargs:
478-
import warnings
479-
warnings.warn(
480-
"The 'response_mode' parameter is deprecated and will be removed in a future version. "
481-
"Response mode is always 'form_post' for security reasons.",
482-
DeprecationWarning,
483-
stacklevel=2)
484485
response_type = kwargs.pop("response_type", "code") # Auth Code flow
485486
# Must be "code" when you are using Authorization Code Grant.
486487
# The "token" for Implicit Grant is not applicable thus not allowed.

tests/test_response_mode.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,23 @@ def test_query_mode_override_with_successful_post_authentication(self):
5454
# Verify deprecation warning
5555
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
5656
self.assertEqual(len(deprecation_warnings), 1, "Should have exactly one deprecation warning")
57-
self.assertIn("deprecated", str(deprecation_warnings[0].message).lower())
57+
warning_msg = str(deprecation_warnings[0].message).lower()
58+
self.assertIn("deprecated", warning_msg)
59+
self.assertIn("public clients", warning_msg)
60+
self.assertIn("confidential clients", warning_msg)
5861

59-
# Verify override warning
62+
# Verify security warning for non-form_post
6063
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
61-
self.assertEqual(len(user_warnings), 1, "Should have exactly one override warning")
62-
self.assertIn("overridden", str(user_warnings[0].message).lower())
64+
self.assertEqual(len(user_warnings), 1, "Should have exactly one security warning")
65+
self.assertIn("form_post", str(user_warnings[0].message).lower())
6366

64-
# Part 2: Verify form_post is enforced in auth_uri
67+
# Part 2: Verify response_mode parameter in flow
68+
# Note: At oauth2.py level, response_mode is passed through as-is.
69+
# The enforcement to form_post happens in authcode.py when opening the browser.
6570
parsed = urlparse(flow["auth_uri"])
6671
params = parse_qs(parsed.query)
72+
# The flow still contains the query mode at this level
6773
self.assertIn("response_mode", params)
68-
self.assertEqual(params["response_mode"][0], "form_post",
69-
"response_mode should be overridden to form_post")
7074

7175
# Part 3: Verify actual POST authentication works (form_post flow)
7276
with AuthCodeReceiver() as receiver:
@@ -116,10 +120,10 @@ def send_get_request():
116120
)
117121
)
118122

119-
# Verify the GET request is rejected
120-
self.assertEqual(response.status_code, 400, "GET with auth code should be rejected")
121-
self.assertIn("not supported", response.text.lower(),
122-
"Error message should indicate method not supported")
123+
# Verify the GET request shows welcome page (doesn't process auth code)
124+
# When no welcome_template is provided, an empty 200 response is returned
125+
self.assertEqual(response.status_code, 200, "GET request should return 200")
126+
# The key is that it doesn't set auth_response - the auth code is ignored
123127

124128
receiver._scheduled_actions = [(1, send_get_request)]
125129

0 commit comments

Comments
 (0)