Skip to content

Commit cf0251e

Browse files
authored
Add browser-based authentication method (#375)
* Add browser-based authentication method * Do not inspect type, just set the attr * Add full end-to-end login test * Be even more explicit in the test about reificiation
1 parent 409c2e6 commit cf0251e

File tree

5 files changed

+214
-0
lines changed

5 files changed

+214
-0
lines changed

README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ the client must specify the expected type: ``kinto_http.BearerTokenAuth("XYPJTNs
100100

101101
In other words, ``kinto_http.Client(auth="Bearer+OIDC XYPJTNsFKV2")`` is equivalent to ``kinto_http.Client(auth=kinto_http.BearerTokenAuth("XYPJTNsFKV2", type="Bearer+OIDC"))``
102102

103+
Using the browser to authenticate via OAuth
104+
-------------------------------------------
105+
106+
.. code-block:: python
107+
108+
import kinto_http
109+
110+
client = kinto_http.Client(server_url='http://localhost:8888/v1', auth=kinto_http.BrowserOAuth())
111+
112+
The client will open a browser page and will catch the Bearer token obtained after the OAuth dance.
113+
103114

104115
Custom headers
105116
--------------

src/kinto_http/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
KintoBatchException,
1111
KintoException,
1212
)
13+
from kinto_http.login import BrowserOAuth
1314
from kinto_http.session import Session, create_session
1415

1516

1617
logger = logging.getLogger("kinto_http")
1718

1819
__all__ = (
20+
"BrowserOAuth",
1921
"BearerTokenAuth",
2022
"Endpoints",
2123
"Session",

src/kinto_http/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ def __init__(
4848
):
4949
self.endpoints = Endpoints()
5050

51+
try:
52+
# See `BrowserOAuth` in login.py (for example).
53+
auth.server_url = server_url
54+
except AttributeError:
55+
pass
56+
5157
session_kwargs = dict(
5258
server_url=server_url,
5359
auth=auth,

src/kinto_http/login.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import base64
2+
import json
3+
import threading
4+
import webbrowser
5+
from http.server import BaseHTTPRequestHandler, HTTPServer
6+
from urllib.parse import unquote
7+
8+
import requests
9+
10+
11+
class RequestHandler(BaseHTTPRequestHandler):
12+
def __init__(self, *args, set_jwt_token_callback=None, **kwargs):
13+
self.set_jwt_token_callback = set_jwt_token_callback
14+
super().__init__(*args, **kwargs)
15+
16+
def do_GET(self):
17+
# Ignore non-auth requests (eg. favicon.ico).
18+
if "/auth" not in self.path: # pragma: no cover
19+
self.send_response(404)
20+
self.end_headers()
21+
return
22+
23+
# Return a basic page to the user inviting them to close the page.
24+
self.send_response(200)
25+
self.send_header("Content-Type", "text/html")
26+
self.end_headers()
27+
self.wfile.write(
28+
b"<html><body><h1>Login successful</h1>You can close this page.</body></html>"
29+
)
30+
31+
# Decode the JWT token
32+
encoded_jwt_token = unquote(self.path.replace("/auth/", ""))
33+
decoded_data = base64.urlsafe_b64decode(encoded_jwt_token + "====").decode("utf-8")
34+
jwt_data = json.loads(decoded_data)
35+
self.set_jwt_token_callback(jwt_data)
36+
# We don't want to stop the server immediately or it won't be
37+
# able to serve the request response.
38+
threading.Thread(target=self.server.shutdown).start()
39+
40+
41+
class BrowserOAuth(requests.auth.AuthBase):
42+
def __init__(self, provider=None):
43+
"""
44+
@param method: Name of the OpenID provider to get OAuth details from.
45+
"""
46+
self.provider = provider
47+
self.header_type = None
48+
self.token = None
49+
50+
def set_jwt_token(self, jwt_data):
51+
self.header_type = jwt_data["token_type"]
52+
self.token = jwt_data["access_token"]
53+
54+
def __call__(self, r):
55+
if self.token is not None:
56+
r.headers["Authorization"] = "{} {}".format(self.header_type, self.token)
57+
return r
58+
59+
# Fetch OpenID capabilities from the server root URL.
60+
resp = requests.get(self.server_url + "/")
61+
server_info = resp.json()
62+
openid_info = server_info["capabilities"]["openid"]
63+
if self.provider is None:
64+
provider_info = openid_info["providers"][0]
65+
else:
66+
provider_info = [p for p in openid_info["providers"] if p["name"] == self.provider][0]
67+
68+
# Spawn a local server on a random port, in order to receive the OAuth dance
69+
# redirection and JWT token content.
70+
http_server = HTTPServer(
71+
("", 0),
72+
lambda *args, **kwargs: RequestHandler(
73+
*args, set_jwt_token_callback=self.set_jwt_token, **kwargs
74+
),
75+
)
76+
port = http_server.server_address[1]
77+
redirect = f"http://localhost:{port}/auth/"
78+
navigate_url = (
79+
self.server_url
80+
+ provider_info["auth_path"]
81+
+ f"?callback={redirect}&scope=openid email"
82+
)
83+
webbrowser.open(navigate_url)
84+
85+
# Serve until the first request is received.
86+
http_server.serve_forever()
87+
88+
# At this point JWT details were obtained.
89+
r.headers["Authorization"] = "{} {}".format(self.header_type, self.token)
90+
return r

tests/test_login.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import base64
2+
import json
3+
import threading
4+
from http.server import BaseHTTPRequestHandler, HTTPServer
5+
from unittest import mock
6+
from urllib.parse import parse_qs, quote, urlparse
7+
8+
import pytest
9+
import requests
10+
11+
from kinto_http.login import BrowserOAuth
12+
13+
14+
class RequestHandler(BaseHTTPRequestHandler):
15+
def __init__(self, body, *args, **kwargs):
16+
self.body = body
17+
super().__init__(*args, **kwargs)
18+
19+
def do_GET(self):
20+
self.send_response(200)
21+
self.send_header("Content-Type", "application/json")
22+
self.end_headers()
23+
self.wfile.write(json.dumps(self.body).encode("utf-8"))
24+
25+
26+
@pytest.fixture
27+
def http_server():
28+
rs_server = HTTPServer(
29+
("", 0),
30+
lambda *args, **kwargs: RequestHandler(
31+
{
32+
"capabilities": {
33+
"openid": {
34+
"providers": [
35+
{
36+
"name": "other",
37+
"auth_path": "/openid/ldap/login",
38+
},
39+
{
40+
"name": "ldap",
41+
"auth_path": "/openid/ldap/login",
42+
},
43+
]
44+
}
45+
}
46+
},
47+
*args,
48+
**kwargs,
49+
),
50+
)
51+
rs_server.port = rs_server.server_address[1]
52+
threading.Thread(target=rs_server.serve_forever).start()
53+
54+
yield rs_server
55+
56+
rs_server.shutdown()
57+
58+
59+
@pytest.fixture
60+
def mock_oauth_dance():
61+
def simulate_navigate(url):
62+
"""
63+
Behave as the user going through the OAuth dance in the browser.
64+
"""
65+
parsed = urlparse(url)
66+
qs = parse_qs(parsed.query)
67+
callback_url = qs["callback"][0]
68+
69+
token = {
70+
"token_type": "Bearer",
71+
"access_token": "fake-token",
72+
}
73+
json_token = json.dumps(token).encode("utf-8")
74+
json_base64 = base64.urlsafe_b64encode(json_token)
75+
encoded_token = quote(json_base64)
76+
# This will open the local server started in `login.py`.
77+
threading.Thread(target=lambda: requests.get(callback_url + encoded_token)).start()
78+
79+
with mock.patch("kinto_http.login.webbrowser") as mocked:
80+
mocked.open.side_effect = simulate_navigate
81+
yield
82+
83+
84+
def test_uses_first_openid_provider(mock_oauth_dance, http_server):
85+
auth = BrowserOAuth()
86+
auth.server_url = f"http://localhost:{http_server.port}/v1"
87+
88+
req = requests.Request()
89+
auth(req)
90+
assert "Bearer fake-token" in req.headers["Authorization"]
91+
92+
# Can be called infinitely and does not rely on remote server.
93+
http_server.shutdown()
94+
req = requests.Request()
95+
auth(req)
96+
assert "Bearer fake-token" in req.headers["Authorization"]
97+
98+
99+
def test_uses_specified_openid_provider(mock_oauth_dance, http_server):
100+
auth = BrowserOAuth(provider="ldap")
101+
auth.server_url = f"http://localhost:{http_server.port}/v1"
102+
103+
req = requests.Request()
104+
auth(req)
105+
assert "Bearer fake-token" in req.headers["Authorization"]

0 commit comments

Comments
 (0)