|
5 | 5 | It optionally opens a browser window to guide a human user to manually login. |
6 | 6 | After obtaining an auth code, the web server will automatically shut down. |
7 | 7 | """ |
8 | | -from collections import defaultdict |
9 | 8 | import logging |
10 | 9 | import os |
11 | 10 | import socket |
@@ -110,47 +109,70 @@ def _printify(text): |
110 | 109 |
|
111 | 110 | class _AuthCodeHandler(BaseHTTPRequestHandler): |
112 | 111 | 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 | + |
113 | 115 | qs = parse_qs(urlparse(self.path).query) |
114 | | - if qs: |
| 116 | + if qs.get('code') or qs.get('error'): |
115 | 117 | # GET request with auth code or error - reject for security (form_post only) |
116 | 118 | self._send_full_response( |
117 | | - "response_mode=query is not supported for authentication responses. " |
118 | | - "This application operates in response_mode=form_post mode only.", |
| 119 | + "GET method is not supported for authentication responses. " |
| 120 | + "This application requires form_post response mode.", |
119 | 121 | 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 |
120 | 128 | else: |
121 | 129 | # Other GET requests - show welcome page |
122 | 130 | self._send_full_response(self.server.welcome_page) |
123 | 131 | # NOTE: Don't do self.server.shutdown() here. It'll halt the server. |
124 | 132 |
|
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') |
| 133 | + def do_POST(self): |
| 134 | + # Handle form_post response mode where auth code is sent via POST body |
128 | 135 | content_length = int(self.headers.get('Content-Length', 0)) |
129 | 136 | post_data = self.rfile.read(content_length).decode('utf-8') |
| 137 | + |
130 | 138 | qs = parse_qs(post_data) |
131 | 139 | if qs.get('code') or qs.get('error'): # So, it is an auth response |
132 | | - self._process_auth_response(_qs2kv(qs)) |
| 140 | + auth_response = _qs2kv(qs) |
| 141 | + logger.debug("Got auth response via POST: %s", auth_response) |
| 142 | + self._process_auth_response(auth_response) |
133 | 143 | else: |
134 | 144 | self._send_full_response("Invalid POST request", is_ok=False) |
135 | 145 | # NOTE: Don't do self.server.shutdown() here. It'll halt the server. |
136 | 146 |
|
137 | 147 | def _process_auth_response(self, auth_response): |
138 | 148 | """Process the auth response from either GET or POST request.""" |
139 | | - logger.debug("Got auth response: %s", auth_response) |
140 | 149 | if self.server.auth_state and self.server.auth_state != auth_response.get("state"): |
141 | 150 | # OAuth2 successful and error responses contain state when it was used |
142 | 151 | # https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1 |
143 | | - self._send_full_response( # Possibly an attack |
144 | | - "State mismatch. Waiting for next response... or you may abort.", is_ok=False) |
| 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 |
145 | 155 | else: |
146 | 156 | template = (self.server.success_template |
147 | 157 | if "code" in auth_response else self.server.error_template) |
148 | 158 | if _is_html(template.template): |
149 | 159 | safe_data = _escape(auth_response) # Foiling an XSS attack |
150 | 160 | else: |
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)) |
| 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)) |
154 | 176 | self.server.auth_response = auth_response # Set it now, after the response is likely sent |
155 | 177 |
|
156 | 178 | def _send_full_response(self, body, is_ok=True): |
@@ -236,7 +258,6 @@ def get_auth_response(self, timeout=None, **kwargs): |
236 | 258 |
|
237 | 259 | :param str auth_uri: |
238 | 260 | 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. |
240 | 261 | :param int timeout: In seconds. None means wait indefinitely. |
241 | 262 | :param str state: |
242 | 263 | You may provide the state you used in auth_uri, |
@@ -309,20 +330,17 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None, |
309 | 330 | welcome_uri = "http://localhost:{p}".format(p=self.get_port()) |
310 | 331 | abort_uri = "{loc}?error=abort".format(loc=welcome_uri) |
311 | 332 | logger.debug("Abort by visit %s", abort_uri) |
312 | | - |
| 333 | + |
| 334 | + # Enforce response_mode=form_post for security |
313 | 335 | if auth_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) |
| 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) |
326 | 344 | if auth_uri: # Now attempt to open a local browser to visit it |
327 | 345 | _uri = welcome_uri if welcome_template else auth_uri |
328 | 346 | logger.info("Open a browser on this device to visit: %s" % _uri) |
@@ -351,22 +369,22 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None, |
351 | 369 | auth_uri_callback(_uri) |
352 | 370 |
|
353 | 371 | self._server.success_template = Template(success_template or |
354 | | - "Authentication complete. You can return to the application. Please close this browser tab.") |
| 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.") |
355 | 374 | self._server.error_template = Template(error_template or |
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)") |
| 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.") |
360 | 377 |
|
361 | 378 | self._server.timeout = timeout # Otherwise its handle_timeout() won't work |
362 | 379 | self._server.auth_response = {} # Shared with _AuthCodeHandler |
363 | 380 | 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 |
364 | 382 | while not self._closing: # Otherwise, the handle_request() attempt |
365 | 383 | # would yield noisy ValueError trace |
366 | 384 | # Derived from |
367 | 385 | # https://docs.python.org/2/library/basehttpserver.html#more-examples |
368 | 386 | self._server.handle_request() |
369 | | - if self._server.auth_response: |
| 387 | + if self._server.auth_response or self._server.done: |
370 | 388 | break |
371 | 389 | result.update(self._server.auth_response) # Return via writable result param |
372 | 390 |
|
@@ -407,6 +425,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): |
407 | 425 | ) |
408 | 426 | print(json.dumps(receiver.get_auth_response( |
409 | 427 | auth_uri=flow["auth_uri"], |
| 428 | + welcome_template= |
| 429 | + "<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a", |
410 | 430 | error_template="<html>Oh no. $error</html>", |
411 | 431 | success_template="Oh yeah. Got $code", |
412 | 432 | timeout=args.timeout, |
|
0 commit comments