1# Note: This docstring is also used by this script's command line help.
2"""A one-stop helper for desktop app to acquire an authorization code.
3
4It starts a web server to listen redirect_uri, waiting for auth code.
5It optionally opens a browser window to guide a human user to manually login.
6After obtaining an auth code, the web server will automatically shut down.
7"""
8from collections import defaultdict
9import logging
10import os
11import socket
12import sys
13from string import Template
14import threading
15import time
16
17try: # Python 3
18 from http.server import HTTPServer, BaseHTTPRequestHandler
19 from urllib.parse import urlparse, parse_qs, urlencode
20 from html import escape
21except ImportError: # Fall back to Python 2
22 from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
23 from urlparse import urlparse, parse_qs
24 from urllib import urlencode
25 from cgi import escape
26
27
28logger = logging.getLogger(__name__)
29
30
31def obtain_auth_code(listen_port, auth_uri=None): # Historically only used in testing
32 with AuthCodeReceiver(port=listen_port) as receiver:
33 return receiver.get_auth_response(
34 auth_uri=auth_uri,
35 welcome_template="""<html><body>
36 Open this link to <a href='$auth_uri'>Sign In</a>
37 (You may want to use incognito window)
38 <hr><a href='$abort_uri'>Abort</a>
39 </body></html>""",
40 ).get("code")
41
42
43def _is_inside_docker():
44 # Marker files created by the container runtimes themselves are the most
45 # reliable signal. "/.dockerenv" is created by Docker (including Docker
46 # Desktop on Mac); "/run/.containerenv" is created by Podman.
47 if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
48 return True
49 try:
50 with open("/proc/1/cgroup") as f: # https://stackoverflow.com/a/20012536/728675
51 # Search keyword "/proc/pid/cgroup" in this link for the file format
52 # https://man7.org/linux/man-pages/man7/cgroups.7.html
53 for line in f.readlines():
54 cgroup_path = line.split(":", 2)[2].strip()
55 # Only a recognized container cgroup counts as "inside a container".
56 # We must NOT treat any non-"/" path as a container: on a cgroups v2
57 # host, systemd puts PID 1 in "/init.scope" (rather than "/"), which
58 # is not a container and would otherwise cause this server to bind to
59 # 0.0.0.0 (all interfaces) instead of loopback. See issue #886.
60 if any(marker in cgroup_path for marker in (
61 "docker", "containerd", "kubepods", "lxc", "libpod")):
62 return True
63 except IOError:
64 pass # We are probably not running on Linux
65 return False
66
67
68def is_wsl():
69 # "Official" way of detecting WSL: https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364
70 # Run `uname -a` to get 'release' without python
71 # - WSL 1: '4.4.0-19041-Microsoft'
72 # - WSL 2: '4.19.128-microsoft-standard'
73 import platform
74 uname = platform.uname()
75 platform_name = getattr(uname, 'system', uname[0]).lower()
76 release = getattr(uname, 'release', uname[2]).lower()
77 return platform_name == 'linux' and 'microsoft' in release
78
79
80def _browse(auth_uri, browser_name=None): # throws ImportError, webbrowser.Error
81 """Browse uri with named browser. Default browser is customizable by $BROWSER"""
82 try:
83 parsed_uri = urlparse(auth_uri)
84 if parsed_uri.scheme not in ("http", "https"):
85 logger.warning("Invalid URI scheme for browser: %s", parsed_uri.scheme)
86 return False
87 except ValueError:
88 logger.warning("Invalid URI: %s", auth_uri)
89 return False
90 if any(c in auth_uri for c in "\n\r\t"):
91 logger.warning("Invalid characters in URI")
92 return False
93
94 import webbrowser # Lazy import. Some distro may not have this.
95 if browser_name:
96 browser_opened = webbrowser.get(browser_name).open(auth_uri)
97 else:
98 # This one can survive BROWSER=nonexist, while get(None).open(...) can not
99 browser_opened = webbrowser.open(auth_uri)
100
101 # In WSL which doesn't have www-browser, try launching browser with explorer.exe
102 if not browser_opened and is_wsl():
103 import subprocess
104 try: # Try wslview first, which is the recommended way on WSL
105 # https://github.com/wslutilities/wslu
106 exit_code = subprocess.call(['wslview', auth_uri])
107 browser_opened = exit_code == 0
108 except FileNotFoundError: # wslview might not be installed
109 pass
110 if not browser_opened:
111 try:
112 # Fallback to explorer.exe as recommended for WSL
113 # Note: explorer.exe returns 1 on success in some WSL environments
114 exit_code = subprocess.call(['explorer.exe', auth_uri])
115 browser_opened = exit_code in (0, 1)
116 except FileNotFoundError:
117 pass
118 return browser_opened
119
120
121def _qs2kv(qs):
122 """Flatten parse_qs()'s single-item lists into the item itself"""
123 return {k: v[0] if isinstance(v, list) and len(v) == 1 else v
124 for k, v in qs.items()}
125
126
127def _is_html(text):
128 return text.startswith("<") # Good enough for our purpose
129
130
131def _escape(key_value_pairs):
132 return {k: escape(v) for k, v in key_value_pairs.items()}
133
134def _printify(text):
135 # If an https request is sent to an http server, the text needs to be repr-ed
136 return repr(text) if isinstance(text, str) and not text.isprintable() else text
137
138class _AuthCodeHandler(BaseHTTPRequestHandler):
139 def do_GET(self):
140 qs = parse_qs(urlparse(self.path).query)
141 welcome_param = qs.get('welcome', [None])[0]
142 error_param = qs.get('error', [None])[0]
143 if welcome_param == 'true': # Useful in manual e2e tests
144 self._send_full_response(self.server.welcome_page)
145 elif error_param == 'abort': # Useful in manual e2e tests
146 self._send_full_response("Authentication aborted", is_ok=False)
147 elif qs:
148 # GET request with auth code or error - reject for security (form_post only)
149 self._send_full_response(
150 "response_mode=query is not supported for authentication responses. "
151 "This application operates in response_mode=form_post mode only.",
152 is_ok=False)
153 else:
154 # IdP may have error scenarios that result in a parameter-less GET request
155 self._send_full_response(
156 "Authentication could not be completed. You can close this window and return to the application.",
157 is_ok=False)
158 # NOTE: Don't do self.server.shutdown() here. It'll halt the server.
159
160 def do_POST(self): # Handle form_post response where auth code is in body
161 # For flexibility, we choose to not check self.path matching redirect_uri
162 #assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
163 content_length = int(self.headers.get('Content-Length', 0))
164 post_data = self.rfile.read(content_length).decode('utf-8')
165 qs = parse_qs(post_data)
166 if qs.get('code') or qs.get('error'): # So, it is an auth response
167 self._process_auth_response(_qs2kv(qs))
168 else:
169 self._send_full_response("Invalid POST request", is_ok=False)
170 # NOTE: Don't do self.server.shutdown() here. It'll halt the server.
171
172 def _process_auth_response(self, auth_response):
173 """Process the auth response from either GET or POST request."""
174 logger.debug("Got auth response: %s", auth_response)
175 if self.server.auth_state and self.server.auth_state != auth_response.get("state"):
176 # OAuth2 successful and error responses contain state when it was used
177 # https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1
178 self._send_full_response( # Possibly an attack
179 "State mismatch. Waiting for next response... or you may abort.", is_ok=False)
180 else:
181 template = (self.server.success_template
182 if "code" in auth_response else self.server.error_template)
183 if _is_html(template.template):
184 safe_data = _escape(auth_response) # Foiling an XSS attack
185 else:
186 safe_data = auth_response
187 filled_data = defaultdict(str, safe_data) # So that missing keys will be empty string
188 self._send_full_response(template.safe_substitute(**filled_data))
189 self.server.auth_response = auth_response # Set it now, after the response is likely sent
190
191 def _send_full_response(self, body, is_ok=True):
192 self.send_response(200 if is_ok else 400)
193 content_type = 'text/html' if _is_html(body) else 'text/plain'
194 self.send_header('Content-type', content_type)
195 self.end_headers()
196 self.wfile.write(body.encode("utf-8"))
197
198 def log_message(self, format, *args):
199 # To override the default log-to-stderr behavior
200 logger.debug(format, *map(_printify, args))
201
202
203class _AuthCodeHttpServer(HTTPServer, object):
204 def __init__(self, server_address, *args, **kwargs):
205 _, port = server_address
206 if port and (sys.platform == "win32" or is_wsl()):
207 # The default allow_reuse_address is True. It works fine on non-Windows.
208 # On Windows, it undesirably allows multiple servers listening on same port,
209 # yet the second server would not receive any incoming request.
210 # So, we need to turn it off.
211 self.allow_reuse_address = False
212 super(_AuthCodeHttpServer, self).__init__(server_address, *args, **kwargs)
213
214 def handle_timeout(self):
215 # It will be triggered when no request comes in self.timeout seconds.
216 # See https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.handle_timeout
217 raise RuntimeError("Timeout. No auth response arrived.") # Terminates this server
218 # We choose to not call self.server_close() here,
219 # because it would cause a socket.error exception in handle_request(),
220 # and likely end up the server being server_close() twice.
221
222
223class _AuthCodeHttpServer6(_AuthCodeHttpServer):
224 address_family = socket.AF_INET6
225
226
227class AuthCodeReceiver(object):
228 # This class has (rather than is) an _AuthCodeHttpServer, so it does not leak API
229 def __init__(self, port=None, scheduled_actions=None):
230 """Create a Receiver waiting for incoming auth response.
231
232 :param port:
233 The local web server will listen at http://...:<port>
234 You need to use the same port when you register with your app.
235 If your Identity Provider supports dynamic port, you can use port=0 here.
236 Port 0 means to use an arbitrary unused port, per this official example:
237 https://docs.python.org/2.7/library/socketserver.html#asynchronous-mixins
238
239 :param scheduled_actions:
240 For example, if the input is
241 ``[(10, lambda: print("Got stuck during sign in? Call 800-000-0000"))]``
242 then the receiver would call that lambda function after
243 waiting the response for 10 seconds.
244 """
245 address = "0.0.0.0" if _is_inside_docker() else "127.0.0.1" # Hardcode
246 # Per RFC 8252 (https://tools.ietf.org/html/rfc8252#section-8.3):
247 # * Clients should listen on the loopback network interface only.
248 # (It is not recommended to use "" shortcut to bind all addr.)
249 # * the use of localhost is NOT RECOMMENDED.
250 # (Use) the loopback IP literal
251 # rather than localhost avoids inadvertently listening on network
252 # interfaces other than the loopback interface.
253 # Note:
254 # When this server physically listens to a specific IP (as it should),
255 # you will still be able to specify your redirect_uri using either
256 # IP (e.g. 127.0.0.1) or localhost, whichever matches your registration.
257 self._scheduled_actions = sorted(scheduled_actions or []) # Make a copy
258 Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServer
259 # TODO: But, it would treat "localhost" or "" as IPv4.
260 # If pressed, we might just expose a family parameter to caller.
261 self._server = Server((address, port or 0), _AuthCodeHandler)
262 self._closing = False
263
264 def get_port(self):
265 """The port this server actually listening to"""
266 # https://docs.python.org/2.7/library/socketserver.html#SocketServer.BaseServer.server_address
267 return self._server.server_address[1]
268
269 def get_auth_response(self, timeout=None, **kwargs):
270 """Wait and return the auth response. Raise RuntimeError when timeout.
271
272 :param str auth_uri:
273 If provided, this function will try to open a local browser.
274 Starting from 2026, the built-in http server will require response_mode=form_post.
275 :param int timeout: In seconds. None means wait indefinitely.
276 :param str state:
277 You may provide the state you used in auth_uri,
278 then we will use it to validate incoming response.
279 :param str welcome_template:
280 If provided, your end user will see it instead of the auth_uri.
281 When present, it shall be a plaintext or html template following
282 `Python Template string syntax <https://docs.python.org/3/library/string.html#template-strings>`_,
283 and include some of these placeholders: $auth_uri and $abort_uri.
284 :param str success_template:
285 The page will be displayed when authentication was largely successful.
286 Placeholders can be any of these:
287 https://tools.ietf.org/html/rfc6749#section-5.1
288 :param str error_template:
289 The page will be displayed when authentication encountered error.
290 Placeholders can be any of these:
291 https://tools.ietf.org/html/rfc6749#section-5.2
292 :param callable auth_uri_callback:
293 A function with the shape of lambda auth_uri: ...
294 When a browser was unable to be launch, this function will be called,
295 so that the app could tell user to manually visit the auth_uri.
296 :param str browser_name:
297 If you did
298 ``webbrowser.register("xyz", None, BackgroundBrowser("/path/to/browser"))``
299 beforehand, you can pass in the name "xyz" to use that browser.
300 The default value ``None`` means using default browser,
301 which is customizable by env var $BROWSER.
302 :return:
303 The auth response of the first leg of Auth Code flow,
304 typically {"code": "...", "state": "..."} or {"error": "...", ...}
305 See https://tools.ietf.org/html/rfc6749#section-4.1.2
306 and https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
307 Returns None when the state was mismatched, or when timeout occurred.
308 """
309 # Historically, the _get_auth_response() uses HTTPServer.handle_request(),
310 # because its handle-and-retry logic is conceptually as easy as a while loop.
311 # Also, handle_request() honors server.timeout setting, and CTRL+C simply works.
312 # All those are true when running on Linux.
313 #
314 # However, the behaviors on Windows turns out to be different.
315 # A socket server waiting for request would freeze the current thread.
316 # Neither timeout nor CTRL+C would work. End user would have to do CTRL+BREAK.
317 # https://stackoverflow.com/questions/1364173/stopping-python-using-ctrlc
318 #
319 # The solution would need to somehow put the http server into its own thread.
320 # This could be done by the pattern of ``http.server.test()`` which internally
321 # use ``ThreadingHTTPServer.serve_forever()`` (only available in Python 3.7).
322 # Or create our own thread to wrap the HTTPServer.handle_request() inside.
323 result = {} # A mutable object to be filled with thread's return value
324 t = threading.Thread(
325 target=self._get_auth_response, args=(result,), kwargs=kwargs)
326 t.daemon = True # So that it won't prevent the main thread from exiting
327 t.start()
328 begin = time.time()
329 while (time.time() - begin < timeout) if timeout else True:
330 time.sleep(1) # Short detection interval to make happy path responsive
331 if not t.is_alive(): # Then the thread has finished its job and exited
332 break
333 while (self._scheduled_actions
334 and time.time() - begin > self._scheduled_actions[0][0]):
335 _, callback = self._scheduled_actions.pop(0)
336 callback()
337 return result or None
338
339 def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
340 welcome_template=None, success_template=None, error_template=None,
341 auth_uri_callback=None,
342 browser_name=None,
343 ):
344 netloc = "http://localhost:{p}".format(p=self.get_port())
345 abort_uri = "{loc}?error=abort".format(loc=netloc)
346 logger.debug("Abort by visit %s", abort_uri)
347
348 if auth_uri:
349 # Note to maintainers:
350 # Do not enforce response_mode=form_post by secretly hardcoding it here.
351 # Just validate it here, so we won't surprise caller by changing their auth_uri behind the scene.
352 params = parse_qs(urlparse(auth_uri).query)
353 assert params.get('response_mode', [None])[0] == 'form_post', (
354 "The built-in http server supports HTTP POST only. "
355 "The auth_uri must be built with response_mode=form_post")
356
357 self._server.welcome_page = Template(welcome_template or "").safe_substitute(
358 auth_uri=auth_uri, abort_uri=abort_uri)
359 if auth_uri: # Now attempt to open a local browser to visit it
360 _uri = (netloc + "?welcome=true") if welcome_template else auth_uri
361 logger.info("Open a browser on this device to visit: %s" % _uri)
362 browser_opened = False
363 try:
364 browser_opened = _browse(_uri, browser_name=browser_name)
365 except: # Had to use broad except, because the potential
366 # webbrowser.Error is purposely undefined outside of _browse().
367 # Absorb and proceed. Because browser could be manually run elsewhere.
368 logger.exception("_browse(...) unsuccessful")
369 if not browser_opened:
370 if not auth_uri_callback:
371 logger.warning(
372 "Found no browser in current environment. "
373 "If this program is being run inside a container "
374 "which either (1) has access to host network "
375 "(i.e. started by `docker run --net=host -it ...`), "
376 "or (2) published port {port} to host network "
377 "(i.e. started by `docker run -p 127.0.0.1:{port}:{port} -it ...`), "
378 "you can use browser on host to visit the following link. "
379 "Otherwise, this auth attempt would either timeout "
380 "(current timeout setting is {timeout}) "
381 "or be aborted by CTRL+C. Auth URI: {auth_uri}".format(
382 auth_uri=_uri, timeout=timeout, port=self.get_port()))
383 else: # Then it is the auth_uri_callback()'s job to inform the user
384 auth_uri_callback(_uri)
385
386 recommendation = "For your security: Do not share the contents of this page, the address bar, or take screenshots." # From MSRC
387 self._server.success_template = Template(success_template or
388 "Authentication complete. You can return to the application. Please close this browser tab.\n\n" + recommendation)
389 self._server.error_template = Template(error_template or
390 # Do NOT invent new placeholders in this template. Just use standard keys defined in OAuth2 RFC.
391 # Otherwise there is no obvious canonical way for caller to know what placeholders are supported.
392 # Besides, we have been using these standard keys for years. Changing now would break backward compatibility.
393 "Authentication failed. $error: $error_description. ($error_uri).\n\n" + recommendation)
394
395 self._server.timeout = timeout # Otherwise its handle_timeout() won't work
396 self._server.auth_response = {} # Shared with _AuthCodeHandler
397 self._server.auth_state = state # So handler will check it before sending response
398 while not self._closing: # Otherwise, the handle_request() attempt
399 # would yield noisy ValueError trace
400 # Derived from
401 # https://docs.python.org/2/library/basehttpserver.html#more-examples
402 self._server.handle_request()
403 if self._server.auth_response:
404 break
405 result.update(self._server.auth_response) # Return via writable result param
406
407 def close(self):
408 """Either call this eventually; or use the entire class as context manager"""
409 self._closing = True
410 self._server.server_close()
411
412 def __enter__(self):
413 return self
414
415 def __exit__(self, exc_type, exc_val, exc_tb):
416 self.close()
417
418# Note: Manually use or test this module by:
419# python -m path.to.this.file -h
420if __name__ == '__main__':
421 import argparse, json
422 from .oauth2 import Client
423 logging.basicConfig(level=logging.INFO)
424 p = parser = argparse.ArgumentParser(
425 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
426 description=__doc__ + "The auth code received will be shown at stdout.")
427 p.add_argument(
428 '--endpoint', help="The auth endpoint for your app.",
429 default="https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
430 p.add_argument('client_id', help="The client_id of your application")
431 p.add_argument('--port', type=int, default=0, help="The port in redirect_uri")
432 p.add_argument('--timeout', type=int, default=60, help="Timeout value, in second")
433 p.add_argument('--host', default="127.0.0.1", help="The host of redirect_uri")
434 p.add_argument('--scope', default=None, help="The scope list")
435 args = parser.parse_args()
436 client = Client({"authorization_endpoint": args.endpoint}, args.client_id)
437 with AuthCodeReceiver(port=args.port) as receiver:
438 flow = client.initiate_auth_code_flow(
439 scope=args.scope.split() if args.scope else None,
440 redirect_uri="http://{h}:{p}".format(h=args.host, p=receiver.get_port()),
441 )
442 print(json.dumps(receiver.get_auth_response(
443 auth_uri=flow["auth_uri"],
444 welcome_template=
445 "<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a>",
446 error_template="<html>Oh no. $error</html>",
447 success_template="Oh yeah. Got $code",
448 timeout=args.timeout,
449 state=flow["state"], # Optional
450 ), indent=4))