Skip to content

Commit 991d4b1

Browse files
committed
Refactor to specify response_mode=form_post in a better spot
1 parent d3cd6f5 commit 991d4b1

File tree

3 files changed

+72
-172
lines changed

3 files changed

+72
-172
lines changed

msal/oauth2cli/authcode.py

Lines changed: 34 additions & 54 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,70 +110,47 @@ def _printify(text):
109110

110111
class _AuthCodeHandler(BaseHTTPRequestHandler):
111112
def do_GET(self):
112-
# For flexibility, we choose to not check self.path matching redirect_uri
113-
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
114-
115113
qs = parse_qs(urlparse(self.path).query)
116-
if qs.get('code') or qs.get('error'):
114+
if qs:
117115
# GET request with auth code or error - reject for security (form_post only)
118116
self._send_full_response(
119-
"GET method is not supported for authentication responses. "
120-
"This application requires form_post response mode.",
117+
"response_mode=query is not supported for authentication responses. "
118+
"This application operates in response_mode=form_post mode only.",
121119
is_ok=False)
122-
elif not qs:
123-
# Blank redirect from eSTS error - show generic error and mark done
124-
self._send_full_response(
125-
"Authentication could not be completed. "
126-
"You can close this window and return to the application.")
127-
self.server.done = True
128120
else:
129121
# Other GET requests - show welcome page
130122
self._send_full_response(self.server.welcome_page)
131123
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
132124

133-
def do_POST(self):
134-
# Handle form_post response mode where auth code is sent via POST body
125+
def do_POST(self): # Handle form_post response where auth code is in body
126+
# For flexibility, we choose to not check self.path matching redirect_uri
127+
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
135128
content_length = int(self.headers.get('Content-Length', 0))
136129
post_data = self.rfile.read(content_length).decode('utf-8')
137-
138130
qs = parse_qs(post_data)
139131
if qs.get('code') or qs.get('error'): # So, it is an auth response
140-
auth_response = _qs2kv(qs)
141-
logger.debug("Got auth response via POST: %s", auth_response)
142-
self._process_auth_response(auth_response)
132+
self._process_auth_response(_qs2kv(qs))
143133
else:
144134
self._send_full_response("Invalid POST request", is_ok=False)
145135
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
146136

147137
def _process_auth_response(self, auth_response):
148138
"""Process the auth response from either GET or POST request."""
139+
logger.debug("Got auth response: %s", auth_response)
149140
if self.server.auth_state and self.server.auth_state != auth_response.get("state"):
150141
# OAuth2 successful and error responses contain state when it was used
151142
# https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1
152-
self._send_full_response("State mismatch") # Possibly an attack
153-
# Don't set auth_response for security, but mark as done to avoid hanging
154-
self.server.done = True
143+
self._send_full_response( # Possibly an attack
144+
"State mismatch. Waiting for next response... or you may abort.", is_ok=False)
155145
else:
156146
template = (self.server.success_template
157147
if "code" in auth_response else self.server.error_template)
158148
if _is_html(template.template):
159149
safe_data = _escape(auth_response) # Foiling an XSS attack
160150
else:
161-
safe_data = dict(auth_response) # Make a copy to avoid mutating original
162-
# Provide default values for common OAuth2 response fields
163-
# to avoid showing literal placeholder text like "$error_description"
164-
safe_data.setdefault("error", "")
165-
safe_data.setdefault("error_description", "")
166-
# Format error message nicely: include ": description." only if description exists
167-
if "code" not in auth_response: # This is an error response
168-
error_desc = auth_response.get("error_description", "").strip()
169-
if error_desc:
170-
safe_data["error_message"] = f"{safe_data['error']}: {error_desc}."
171-
else:
172-
safe_data["error_message"] = safe_data["error"]
173-
else:
174-
safe_data["error_message"] = ""
175-
self._send_full_response(template.safe_substitute(**safe_data))
151+
safe_data = auth_response
152+
filled_data = defaultdict(str, safe_data) # So that missing keys will be empty string
153+
self._send_full_response(template.safe_substitute(**filled_data))
176154
self.server.auth_response = auth_response # Set it now, after the response is likely sent
177155

178156
def _send_full_response(self, body, is_ok=True):
@@ -258,6 +236,7 @@ def get_auth_response(self, timeout=None, **kwargs):
258236
259237
:param str auth_uri:
260238
If provided, this function will try to open a local browser.
239+
Starting from 2026, the built-in http server will require response_mode=form_post.
261240
:param int timeout: In seconds. None means wait indefinitely.
262241
:param str state:
263242
You may provide the state you used in auth_uri,
@@ -330,17 +309,20 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
330309
welcome_uri = "http://localhost:{p}".format(p=self.get_port())
331310
abort_uri = "{loc}?error=abort".format(loc=welcome_uri)
332311
logger.debug("Abort by visit %s", abort_uri)
333-
334-
# Enforce response_mode=form_post for security
312+
335313
if auth_uri:
336-
parsed = urlparse(auth_uri)
337-
params = parse_qs(parsed.query)
338-
params['response_mode'] = ['form_post'] # Enforce form_post
339-
new_query = urlencode(params, doseq=True)
340-
auth_uri = parsed._replace(query=new_query).geturl()
341-
342-
self._server.welcome_page = Template(welcome_template or "").safe_substitute(
343-
auth_uri=auth_uri, abort_uri=abort_uri)
314+
# Note to maintainers:
315+
# Do not enforce response_mode=form_post by secretly hardcoding it here.
316+
# Just validate it here, so we won't surprise caller by changing their auth_uri behind the scene.
317+
params = parse_qs(urlparse(auth_uri).query)
318+
assert params.get('response_mode', [None])[0] == 'form_post', (
319+
"The built-in http server supports HTTP POST only. "
320+
"The auth_uri must be built with response_mode=form_post")
321+
322+
self._server.welcome_page = Template(
323+
welcome_template or
324+
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a>"
325+
).safe_substitute(auth_uri=auth_uri, abort_uri=abort_uri)
344326
if auth_uri: # Now attempt to open a local browser to visit it
345327
_uri = welcome_uri if welcome_template else auth_uri
346328
logger.info("Open a browser on this device to visit: %s" % _uri)
@@ -369,22 +351,22 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
369351
auth_uri_callback(_uri)
370352

371353
self._server.success_template = Template(success_template or
372-
"Authentication complete. You can return to the application. Please close this browser tab.\n\n"
373-
"For your security: Do not share the contents of this page, the address bar, or take screenshots.")
354+
"Authentication complete. You can return to the application. Please close this browser tab.")
374355
self._server.error_template = Template(error_template or
375-
"Authentication failed. $error_message\n\n"
376-
"For your security: Do not share the contents of this page, the address bar, or take screenshots.")
356+
# Do NOT invent new placeholders in this template. Just use standard keys defined in OAuth2 RFC.
357+
# Otherwise there is no obvious canonical way for caller to know what placeholders are supported.
358+
# Besides, we have been using these standard keys for years. Changing now would break backward compatibility.
359+
"Authentication failed. $error: $error_description. ($error_uri)")
377360

378361
self._server.timeout = timeout # Otherwise its handle_timeout() won't work
379362
self._server.auth_response = {} # Shared with _AuthCodeHandler
380363
self._server.auth_state = state # So handler will check it before sending response
381-
self._server.done = False # Flag to indicate completion without setting auth_response
382364
while not self._closing: # Otherwise, the handle_request() attempt
383365
# would yield noisy ValueError trace
384366
# Derived from
385367
# https://docs.python.org/2/library/basehttpserver.html#more-examples
386368
self._server.handle_request()
387-
if self._server.auth_response or self._server.done:
369+
if self._server.auth_response:
388370
break
389371
result.update(self._server.auth_response) # Return via writable result param
390372

@@ -425,8 +407,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
425407
)
426408
print(json.dumps(receiver.get_auth_response(
427409
auth_uri=flow["auth_uri"],
428-
welcome_template=
429-
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a",
430410
error_template="<html>Oh no. $error</html>",
431411
success_template="Oh yeah. Got $code",
432412
timeout=args.timeout,

msal/oauth2cli/oauth2.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,7 @@ 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-
params.update(kwargs)
180-
if 'response_mode' in params:
181-
import warnings
182-
warnings.warn(
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)
179+
params.update(kwargs) # Note: None values will override params
195180
params = {k: v for k, v in params.items() if v is not None} # clean up
196181
if params.get('scope'):
197182
params['scope'] = self._stringify(params['scope'])
@@ -411,12 +396,21 @@ def obtain_token_by_device_flow(self,
411396

412397
def _build_auth_request_uri(
413398
self,
414-
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):
415403
if "authorization_endpoint" not in self.configuration:
416404
raise ValueError("authorization_endpoint not found in configuration")
417405
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+
)
418411
params = self._build_auth_request_params(
419412
response_type, redirect_uri=redirect_uri, scope=scope, state=state,
413+
response_mode=response_mode,
420414
**kwargs)
421415
sep = '&' if '?' in authorization_endpoint else '?'
422416
return "%s%s%s" % (authorization_endpoint, sep, urlencode(params))
@@ -684,6 +678,7 @@ def _obtain_token_by_browser(
684678
flow = self.initiate_auth_code_flow(
685679
redirect_uri=redirect_uri,
686680
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
687682
**(auth_params or {}))
688683
auth_response = auth_code_receiver.get_auth_response(
689684
auth_uri=flow["auth_uri"],

0 commit comments

Comments
 (0)