55It optionally opens a browser window to guide a human user to manually login.
66After obtaining an auth code, the web server will automatically shut down.
77"""
8+ from collections import defaultdict
89import logging
910import os
1011import socket
@@ -109,29 +110,57 @@ def _printify(text):
109110
110111class _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 ,
0 commit comments