Skip to content

Commit b53684c

Browse files
committed
Merge branch 'improve-authcode-receiver' into dev
2 parents 1846f91 + 73a444c commit b53684c

File tree

2 files changed

+175
-56
lines changed

2 files changed

+175
-56
lines changed

oauth2cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
__version__ = "0.3.0"
1+
__version__ = "0.4.0"
22

33
from .oidc import Client
44
from .assertion import JwtAssertionCreator
55
from .assertion import JwtSigner # Obsolete. For backward compatibility.
6+
from .authcode import AuthCodeReceiver
67

oauth2cli/authcode.py

Lines changed: 173 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
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-
9-
import argparse
108
import webbrowser
119
import logging
10+
import socket
11+
from string import Template
1212

1313
try: # Python 3
1414
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -18,45 +18,23 @@
1818
from urlparse import urlparse, parse_qs
1919
from urllib import urlencode
2020

21-
from .oauth2 import Client
22-
2321

2422
logger = logging.getLogger(__name__)
2523

26-
def obtain_auth_code(listen_port, auth_uri=None):
27-
"""This function will start a web server listening on http://localhost:port
28-
and then you need to open a browser on this device and visit your auth_uri.
29-
When interaction finishes, this function will return the auth code,
30-
and then shut down the local web server.
31-
32-
:param listen_port:
33-
The local web server will listen at http://localhost:<listen_port>
34-
Unless the authorization server supports dynamic port,
35-
you need to use the same port when you register with your app.
36-
:param auth_uri: If provided, this function will try to open a local browser.
37-
:return: Hang indefinitely, until it receives and then return the auth code.
38-
"""
39-
exit_hint = "Visit http://localhost:{p}?code=exit to abort".format(p=listen_port)
40-
logger.warning(exit_hint)
41-
if auth_uri:
42-
page = "http://localhost:{p}?{q}".format(p=listen_port, q=urlencode({
43-
"text": "Open this link to sign in. You may use incognito window",
44-
"link": auth_uri,
45-
"exit_hint": exit_hint,
46-
}))
47-
browse(page)
48-
server = HTTPServer(("", int(listen_port)), AuthCodeReceiver)
49-
try:
50-
server.authcode = None
51-
while not server.authcode:
52-
# Derived from
53-
# https://docs.python.org/2/library/basehttpserver.html#more-examples
54-
server.handle_request()
55-
return server.authcode
56-
finally:
57-
server.server_close()
5824

59-
def browse(auth_uri):
25+
def obtain_auth_code(listen_port, auth_uri=None): # Historically only used in testing
26+
with AuthCodeReceiver(port=listen_port) as receiver:
27+
return receiver.get_auth_response(
28+
auth_uri=auth_uri,
29+
welcome_template="""<html><body>
30+
Open this link to <a href='$auth_uri'>Sign In</a>
31+
(You may want to use incognito window)
32+
<hr><a href='$abort_uri'>Abort</a>
33+
</body></html>""",
34+
).get("code")
35+
36+
37+
def _browse(auth_uri):
6038
controller = webbrowser.get() # Get a default controller
6139
# Some Linux Distro does not setup default browser properly,
6240
# so we try to explicitly use some popular browser, if we found any.
@@ -69,23 +47,28 @@ def browse(auth_uri):
6947
logger.info("Please open a browser on THIS device to visit: %s" % auth_uri)
7048
controller.open(auth_uri)
7149

72-
class AuthCodeReceiver(BaseHTTPRequestHandler):
50+
51+
def _qs2kv(qs):
52+
"""Flatten parse_qs()'s single-item lists into the item itself"""
53+
return {k: v[0] if isinstance(v, list) and len(v) == 1 else v
54+
for k, v in qs.items()}
55+
56+
57+
class _AuthCodeHandler(BaseHTTPRequestHandler):
7358
def do_GET(self):
7459
# For flexibility, we choose to not check self.path matching redirect_uri
7560
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
7661
qs = parse_qs(urlparse(self.path).query)
77-
if qs.get('code'): # Then store it into the server instance
78-
ac = self.server.authcode = qs['code'][0]
79-
self._send_full_response('Authcode:\n{}'.format(ac))
80-
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
81-
elif qs.get('text') and qs.get('link'): # Then display a landing page
62+
if qs.get('code') or qs.get("error"): # So, it is an auth response
63+
self.server.auth_response = _qs2kv(qs)
64+
logger.debug("Got auth response: %s", self.server.auth_response)
65+
template = (self.server.success_template
66+
if "code" in qs else self.server.error_template)
8267
self._send_full_response(
83-
'<a href={link}>{text}</a><hr/>{exit_hint}'.format(
84-
link=qs['link'][0], text=qs['text'][0],
85-
exit_hint=qs.get("exit_hint", [''])[0],
86-
))
68+
template.safe_substitute(**self.server.auth_response))
69+
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
8770
else:
88-
self._send_full_response("This web service serves your redirect_uri")
71+
self._send_full_response(self.server.welcome_page)
8972

9073
def _send_full_response(self, body, is_ok=True):
9174
self.send_response(200 if is_ok else 400)
@@ -95,17 +78,152 @@ def _send_full_response(self, body, is_ok=True):
9578
self.wfile.write(body.encode("utf-8"))
9679

9780

81+
class _AuthCodeHttpServer(HTTPServer):
82+
def handle_timeout(self):
83+
# It will be triggered when no request comes in self.timeout seconds.
84+
# See https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.handle_timeout
85+
raise RuntimeError("Timeout. No auth response arrived.") # Terminates this server
86+
# We choose to not call self.server_close() here,
87+
# because it would cause a socket.error exception in handle_request(),
88+
# and likely end up the server being server_close() twice.
89+
90+
91+
class _AuthCodeHttpServer6(_AuthCodeHttpServer):
92+
address_family = socket.AF_INET6
93+
94+
95+
class AuthCodeReceiver(object):
96+
# This class has (rather than is) an _AuthCodeHttpServer, so it does not leak API
97+
def __init__(self, port=None):
98+
"""Create a Receiver waiting for incoming auth response.
99+
100+
:param port:
101+
The local web server will listen at http://...:<port>
102+
You need to use the same port when you register with your app.
103+
If your Identity Provider supports dynamic port, you can use port=0 here.
104+
Port 0 means to use an arbitrary unused port, per this official example:
105+
https://docs.python.org/2.7/library/socketserver.html#asynchronous-mixins
106+
"""
107+
address = "127.0.0.1" # Hardcode, for now, Not sure what to expose, yet.
108+
# Per RFC 8252 (https://tools.ietf.org/html/rfc8252#section-8.3):
109+
# * Clients should listen on the loopback network interface only.
110+
# (It is not recommended to use "" shortcut to bind all addr.)
111+
# * the use of localhost is NOT RECOMMENDED.
112+
# (Use) the loopback IP literal
113+
# rather than localhost avoids inadvertently listening on network
114+
# interfaces other than the loopback interface.
115+
# Note:
116+
# When this server physically listens to a specific IP (as it should),
117+
# you will still be able to specify your redirect_uri using either
118+
# IP (e.g. 127.0.0.1) or localhost, whichever matches your registration.
119+
Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServer
120+
# TODO: But, it would treat "localhost" or "" as IPv4.
121+
# If pressed, we might just expose a family parameter to caller.
122+
self._server = Server((address, port or 0), _AuthCodeHandler)
123+
124+
def get_port(self):
125+
"""The port this server actually listening to"""
126+
# https://docs.python.org/2.7/library/socketserver.html#SocketServer.BaseServer.server_address
127+
return self._server.server_address[1]
128+
129+
def get_auth_response(self, auth_uri=None, text=None, timeout=None, state=None,
130+
welcome_template=None, success_template=None, error_template=None):
131+
"""Wait and return the auth response, or None when timeout.
132+
133+
:param str auth_uri:
134+
If provided, this function will try to open a local browser.
135+
:param str text:
136+
If provided (together with auth_uri),
137+
this function will render a landing page with ``text`` in your browser.
138+
This can be used to make testing more readable.
139+
:param int timeout: In seconds. None means wait indefinitely.
140+
:param str state:
141+
You may provide the state you used in auth_url,
142+
then we will use it to validate incoming response.
143+
:param str welcome_template:
144+
If provided, your end user will see it instead of the auth_uri.
145+
When present, it shall be a plaintext or html template following
146+
`Python Template string syntax <https://docs.python.org/3/library/string.html#template-strings>`_,
147+
and include some of these placeholders: $auth_uri and $abort_uri.
148+
:param str success_template:
149+
The page will be displayed when authentication was largely successful.
150+
Placeholders can be any of these:
151+
https://tools.ietf.org/html/rfc6749#section-5.1
152+
:param str error_template:
153+
The page will be displayed when authentication encountered error.
154+
Placeholders can be any of these:
155+
https://tools.ietf.org/html/rfc6749#section-5.2
156+
:return:
157+
The auth response of the first leg of Auth Code flow,
158+
typically {"code": "...", "state": "..."} or {"error": "...", ...}
159+
See https://tools.ietf.org/html/rfc6749#section-4.1.2
160+
and https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
161+
Returns None when the state was mismatched, or when timeout occurred.
162+
"""
163+
welcome_uri = "http://localhost:{p}".format(p=self.get_port())
164+
abort_uri = "{loc}?error=abort".format(loc=welcome_uri)
165+
logger.debug("Abort by visit %s", abort_uri)
166+
self._server.welcome_page = Template(welcome_template or "").safe_substitute(
167+
auth_uri=auth_uri, abort_uri=abort_uri)
168+
if auth_uri:
169+
_browse(welcome_uri if welcome_template else auth_uri)
170+
self._server.success_template = Template(success_template or
171+
"Authentication completed. You can close this window now.")
172+
self._server.error_template = Template(error_template or
173+
"Authentication failed. $error: $error_description. ($error_uri)")
174+
175+
self._server.timeout = timeout # Otherwise its handle_timeout() won't work
176+
self._server.auth_response = {} # Shared with _AuthCodeHandler
177+
while True:
178+
# Derived from
179+
# https://docs.python.org/2/library/basehttpserver.html#more-examples
180+
self._server.handle_request()
181+
if self._server.auth_response:
182+
if state and state != self._server.auth_response.get("state"):
183+
logger.debug("State mismatch. Ignoring this noise.")
184+
else:
185+
break
186+
return self._server.auth_response
187+
188+
def close(self):
189+
"""Either call this eventually; or use the entire class as context manager"""
190+
self._server.server_close()
191+
192+
def __enter__(self):
193+
return self
194+
195+
def __exit__(self, exc_type, exc_val, exc_tb):
196+
self.close()
197+
198+
# Note: Manually use or test this module by:
199+
# python -m path.to.this.file -h
98200
if __name__ == '__main__':
201+
import argparse, json
202+
from .oauth2 import Client
99203
logging.basicConfig(level=logging.INFO)
100204
p = parser = argparse.ArgumentParser(
205+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
101206
description=__doc__ + "The auth code received will be shown at stdout.")
102-
p.add_argument('endpoint',
103-
help="The auth endpoint for your app. For example: "
104-
"https://login.microsoftonline.com/your_tenant/oauth2/authorize")
207+
p.add_argument(
208+
'--endpoint', help="The auth endpoint for your app.",
209+
default="https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
105210
p.add_argument('client_id', help="The client_id of your application")
106-
p.add_argument('redirect_port', type=int, help="The port in redirect_uri")
211+
p.add_argument('--port', type=int, default=0, help="The port in redirect_uri")
212+
p.add_argument('--host', default="127.0.0.1", help="The host of redirect_uri")
213+
p.add_argument('--scope', default=None, help="The scope list")
107214
args = parser.parse_args()
108-
client = Client(args.client_id, authorization_endpoint=args.endpoint)
109-
auth_uri = client.build_auth_request_uri("code")
110-
print(obtain_auth_code(args.redirect_port, auth_uri))
215+
client = Client({"authorization_endpoint": args.endpoint}, args.client_id)
216+
with AuthCodeReceiver(port=args.port) as receiver:
217+
auth_uri = client.build_auth_request_uri(
218+
"code",
219+
scope=args.scope.split() if args.scope else None,
220+
redirect_uri="http://{h}:{p}".format(h=args.host, p=receiver.get_port()))
221+
print(json.dumps(receiver.get_auth_response(
222+
auth_uri=auth_uri,
223+
welcome_template=
224+
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a",
225+
error_template="Oh no. $error",
226+
success_template="Oh yeah. Got $code",
227+
timeout=60,
228+
), indent=4))
111229

0 commit comments

Comments
 (0)