From 7c12eee90430b68a542d04bfd97626160eb22e15 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Wed, 6 Nov 2024 14:37:52 +0100 Subject: [PATCH 1/4] Add browser-based authentication method --- README.rst | 11 +++++ src/kinto_http/__init__.py | 2 + src/kinto_http/client.py | 4 ++ src/kinto_http/login.py | 92 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/kinto_http/login.py diff --git a/README.rst b/README.rst index eda6a7c..32647eb 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 10d1cd9..b30c4c0 100644 --- a/src/kinto_http/client.py +++ b/src/kinto_http/client.py @@ -15,6 +15,7 @@ from kinto_http.constants import DO_NOT_OVERWRITE from kinto_http.endpoints import Endpoints from kinto_http.exceptions import BucketNotFound, CollectionNotFound, KintoException +from kinto_http.login import BrowserOAuth from kinto_http.patch_type import BasicPatch, PatchType from kinto_http.session import create_session @@ -45,6 +46,9 @@ def __init__( ): self.endpoints = Endpoints() + if isinstance(auth, BrowserOAuth): + auth.server_url = server_url + 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..458b4be --- /dev/null +++ b/src/kinto_http/login.py @@ -0,0 +1,92 @@ +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: + 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 provider_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 From 4e9e470c9ef68557ece11f59327e32d2be2c7b51 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Tue, 12 Nov 2024 17:08:24 +0100 Subject: [PATCH 2/4] Do not inspect type, just set the attr --- src/kinto_http/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/kinto_http/client.py b/src/kinto_http/client.py index 3117d54..6bb2c32 100644 --- a/src/kinto_http/client.py +++ b/src/kinto_http/client.py @@ -18,7 +18,6 @@ from kinto_http.constants import DO_NOT_OVERWRITE from kinto_http.endpoints import Endpoints from kinto_http.exceptions import BucketNotFound, CollectionNotFound, KintoException -from kinto_http.login import BrowserOAuth from kinto_http.patch_type import BasicPatch, PatchType from kinto_http.session import create_session @@ -49,8 +48,11 @@ def __init__( ): self.endpoints = Endpoints() - if isinstance(auth, BrowserOAuth): + try: + # See `BrowserOAuth` in login.py (for example). auth.server_url = server_url + except AttributeError: + pass session_kwargs = dict( server_url=server_url, From 8b06feef7c133ddc31bfdd28bdc8a031592af048 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Thu, 14 Nov 2024 12:41:24 +0100 Subject: [PATCH 3/4] Add full end-to-end login test --- src/kinto_http/login.py | 6 +-- tests/test_login.py | 102 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 tests/test_login.py diff --git a/src/kinto_http/login.py b/src/kinto_http/login.py index 458b4be..0b86f9c 100644 --- a/src/kinto_http/login.py +++ b/src/kinto_http/login.py @@ -15,7 +15,7 @@ def __init__(self, *args, set_jwt_token_callback=None, **kwargs): def do_GET(self): # Ignore non-auth requests (eg. favicon.ico). - if "/auth" not in self.path: + if "/auth" not in self.path: # pragma: no cover self.send_response(404) self.end_headers() return @@ -63,9 +63,7 @@ def __call__(self, r): if self.provider is None: provider_info = openid_info["providers"][0] else: - provider_info = [p for p in provider_info["providers"] if p["name"] == self.provider][ - 0 - ] + 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. diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 0000000..224841d --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,102 @@ +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 + auth(req) + + +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"] From ec9dd33106a0d77eb20b0582b164ec7fd8f0c5d3 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Thu, 14 Nov 2024 12:57:48 +0100 Subject: [PATCH 4/4] Be even more explicit in the test about reificiation --- tests/test_login.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_login.py b/tests/test_login.py index 224841d..85c41fb 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -89,8 +89,11 @@ def test_uses_first_openid_provider(mock_oauth_dance, http_server): auth(req) assert "Bearer fake-token" in req.headers["Authorization"] - # Can be called infinitely + # 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):