diff --git a/README.rst b/README.rst index 638ee3b..8a726e0 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,17 @@ the client must specify the expected type: ``kinto_http.BearerTokenAuth("XYPJTNs In other words, ``kinto_http.Client(auth="Bearer+OIDC XYPJTNsFKV2")`` is equivalent to ``kinto_http.Client(auth=kinto_http.BearerTokenAuth("XYPJTNsFKV2", type="Bearer+OIDC"))`` +Using the browser to authenticate via OAuth +------------------------------------------- + +.. code-block:: python + + import kinto_http + + client = kinto_http.Client(server_url='http://localhost:8888/v1', auth=kinto_http.BrowserOAuth()) + +The client will open a browser page and will catch the Bearer token obtained after the OAuth dance. + Custom headers -------------- diff --git a/src/kinto_http/__init__.py b/src/kinto_http/__init__.py index 0912187..87d1c60 100644 --- a/src/kinto_http/__init__.py +++ b/src/kinto_http/__init__.py @@ -10,12 +10,14 @@ KintoBatchException, KintoException, ) +from kinto_http.login import BrowserOAuth from kinto_http.session import Session, create_session logger = logging.getLogger("kinto_http") __all__ = ( + "BrowserOAuth", "BearerTokenAuth", "Endpoints", "Session", diff --git a/src/kinto_http/client.py b/src/kinto_http/client.py index 3cde794..6bb2c32 100644 --- a/src/kinto_http/client.py +++ b/src/kinto_http/client.py @@ -48,6 +48,12 @@ def __init__( ): self.endpoints = Endpoints() + try: + # See `BrowserOAuth` in login.py (for example). + auth.server_url = server_url + except AttributeError: + pass + session_kwargs = dict( server_url=server_url, auth=auth, diff --git a/src/kinto_http/login.py b/src/kinto_http/login.py new file mode 100644 index 0000000..0b86f9c --- /dev/null +++ b/src/kinto_http/login.py @@ -0,0 +1,90 @@ +import base64 +import json +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import unquote + +import requests + + +class RequestHandler(BaseHTTPRequestHandler): + def __init__(self, *args, set_jwt_token_callback=None, **kwargs): + self.set_jwt_token_callback = set_jwt_token_callback + super().__init__(*args, **kwargs) + + def do_GET(self): + # Ignore non-auth requests (eg. favicon.ico). + if "/auth" not in self.path: # pragma: no cover + self.send_response(404) + self.end_headers() + return + + # Return a basic page to the user inviting them to close the page. + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write( + b"

Login successful

You can close this page." + ) + + # Decode the JWT token + encoded_jwt_token = unquote(self.path.replace("/auth/", "")) + decoded_data = base64.urlsafe_b64decode(encoded_jwt_token + "====").decode("utf-8") + jwt_data = json.loads(decoded_data) + self.set_jwt_token_callback(jwt_data) + # We don't want to stop the server immediately or it won't be + # able to serve the request response. + threading.Thread(target=self.server.shutdown).start() + + +class BrowserOAuth(requests.auth.AuthBase): + def __init__(self, provider=None): + """ + @param method: Name of the OpenID provider to get OAuth details from. + """ + self.provider = provider + self.header_type = None + self.token = None + + def set_jwt_token(self, jwt_data): + self.header_type = jwt_data["token_type"] + self.token = jwt_data["access_token"] + + def __call__(self, r): + if self.token is not None: + r.headers["Authorization"] = "{} {}".format(self.header_type, self.token) + return r + + # Fetch OpenID capabilities from the server root URL. + resp = requests.get(self.server_url + "/") + server_info = resp.json() + openid_info = server_info["capabilities"]["openid"] + if self.provider is None: + provider_info = openid_info["providers"][0] + else: + provider_info = [p for p in openid_info["providers"] if p["name"] == self.provider][0] + + # Spawn a local server on a random port, in order to receive the OAuth dance + # redirection and JWT token content. + http_server = HTTPServer( + ("", 0), + lambda *args, **kwargs: RequestHandler( + *args, set_jwt_token_callback=self.set_jwt_token, **kwargs + ), + ) + port = http_server.server_address[1] + redirect = f"http://localhost:{port}/auth/" + navigate_url = ( + self.server_url + + provider_info["auth_path"] + + f"?callback={redirect}&scope=openid email" + ) + webbrowser.open(navigate_url) + + # Serve until the first request is received. + http_server.serve_forever() + + # At this point JWT details were obtained. + r.headers["Authorization"] = "{} {}".format(self.header_type, self.token) + return r diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 0000000..85c41fb --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,105 @@ +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest import mock +from urllib.parse import parse_qs, quote, urlparse + +import pytest +import requests + +from kinto_http.login import BrowserOAuth + + +class RequestHandler(BaseHTTPRequestHandler): + def __init__(self, body, *args, **kwargs): + self.body = body + super().__init__(*args, **kwargs) + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(self.body).encode("utf-8")) + + +@pytest.fixture +def http_server(): + rs_server = HTTPServer( + ("", 0), + lambda *args, **kwargs: RequestHandler( + { + "capabilities": { + "openid": { + "providers": [ + { + "name": "other", + "auth_path": "/openid/ldap/login", + }, + { + "name": "ldap", + "auth_path": "/openid/ldap/login", + }, + ] + } + } + }, + *args, + **kwargs, + ), + ) + rs_server.port = rs_server.server_address[1] + threading.Thread(target=rs_server.serve_forever).start() + + yield rs_server + + rs_server.shutdown() + + +@pytest.fixture +def mock_oauth_dance(): + def simulate_navigate(url): + """ + Behave as the user going through the OAuth dance in the browser. + """ + parsed = urlparse(url) + qs = parse_qs(parsed.query) + callback_url = qs["callback"][0] + + token = { + "token_type": "Bearer", + "access_token": "fake-token", + } + json_token = json.dumps(token).encode("utf-8") + json_base64 = base64.urlsafe_b64encode(json_token) + encoded_token = quote(json_base64) + # This will open the local server started in `login.py`. + threading.Thread(target=lambda: requests.get(callback_url + encoded_token)).start() + + with mock.patch("kinto_http.login.webbrowser") as mocked: + mocked.open.side_effect = simulate_navigate + yield + + +def test_uses_first_openid_provider(mock_oauth_dance, http_server): + auth = BrowserOAuth() + auth.server_url = f"http://localhost:{http_server.port}/v1" + + req = requests.Request() + auth(req) + assert "Bearer fake-token" in req.headers["Authorization"] + + # Can be called infinitely and does not rely on remote server. + http_server.shutdown() + req = requests.Request() + auth(req) + assert "Bearer fake-token" in req.headers["Authorization"] + + +def test_uses_specified_openid_provider(mock_oauth_dance, http_server): + auth = BrowserOAuth(provider="ldap") + auth.server_url = f"http://localhost:{http_server.port}/v1" + + req = requests.Request() + auth(req) + assert "Bearer fake-token" in req.headers["Authorization"]