Skip to content
Snippets Groups Projects
Commit 492be567 authored by Antoine Lambert's avatar Antoine Lambert
Browse files

client: Add OpenID Connect bearer token authentication

By providing a refresh token, obtained with the "swh auth login" command
to the new authenticate method of the client, all emitted requests will
be authenticated using a bearer token sent in headers.

Related to T1927
parent 842b7bc7
No related branches found
No related tags found
No related merge requests found
......@@ -2,6 +2,8 @@
.. include:: README.rst
.. _swh-web-client-auth:
Authentication
--------------
......@@ -81,6 +83,21 @@ again.
$ swh auth refresh $REFRESH_TOKEN
"......."
Note that if you intend to use the :class:`swh.web.client.client.WebAPIClient`
class, the access token renewal will be automatically handled if you call
method :meth:`swh.web.client.client.WebAPIClient.authenticate` prior to
sending any requests. To activate authentication, use the following code snippet::
from swh.web.client import WebAPIClient
REFRESH_TOKEN = '.......' # Use "swh auth login" command to get it
client = WebAPIClient()
client.authenticate(REFRESH_TOKEN)
# All requests to the Web API will be authenticated
resp = client.get('swh:1:rev:aafb16d69fd30ff58afdd69036a26047f3aebdc6')
It is also possible to ``logout`` from the authenticated OpenID Connect session
which invalidates all previously emitted tokens.
......
......@@ -13,6 +13,15 @@ SWH_REALM_NAME = 'SoftwareHeritage'
SWH_WEB_CLIENT_ID = 'swh-web'
class AuthenticationError(Exception):
"""Authentication related error.
Example: A bearer token has expired.
"""
pass
class OpenIDConnectSession:
"""
Simple class wrapping requests sent to an OpenID Connect server.
......
......@@ -28,7 +28,8 @@ conversions and pagination.
"""
from typing import Any, Callable, Dict, Generator, List, Union
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Generator, List, Optional, Union
from urllib.parse import urlparse
import dateutil.parser
......@@ -39,6 +40,9 @@ from swh.model.identifiers import \
from swh.model.identifiers import PersistentId as PID
from swh.model.identifiers import parse_persistent_identifier as parse_pid
from .auth import (
AuthenticationError, OpenIDConnectSession, SWH_OIDC_SERVER_URL
)
PIDish = Union[PID, str]
......@@ -115,7 +119,8 @@ class WebAPIClient:
"""
def __init__(self, api_url='https://archive.softwareheritage.org/api/1'):
def __init__(self, api_url='https://archive.softwareheritage.org/api/1',
auth_url=SWH_OIDC_SERVER_URL):
"""Create a client for the Software Heritage Web API
See: https://archive.softwareheritage.org/api/
......@@ -130,6 +135,8 @@ class WebAPIClient:
self.api_url = api_url
self.api_path = u.path
self.oidc_session = OpenIDConnectSession(oidc_server_url=auth_url)
self.oidc_profile: Optional[Dict[str, Any]] = None
self._getters: Dict[str, Callable[[PIDish], Any]] = {
CONTENT: self.content,
......@@ -159,11 +166,20 @@ class WebAPIClient:
url = '/'.join([self.api_url, query])
r = None
headers = {}
if self.oidc_profile is not None:
# use bearer token authentication
if datetime.now() > self.oidc_profile['expires_at']:
# refresh access token if it has expired
self.authenticate(self.oidc_profile['refresh_token'])
access_token = self.oidc_profile['access_token']
headers = {'Authorization': f'Bearer {access_token}'}
if http_method == 'get':
r = requests.get(url, **req_args)
r = requests.get(url, **req_args, headers=headers)
r.raise_for_status()
elif http_method == 'head':
r = requests.head(url, **req_args)
r = requests.head(url, **req_args, headers=headers)
else:
raise ValueError(f'unsupported HTTP method: {http_method}')
......@@ -397,3 +413,29 @@ class WebAPIClient:
r.raise_for_status()
yield from r.iter_content(chunk_size=None, decode_unicode=False)
def authenticate(self, refresh_token: str):
"""Authenticate API requests using OpenID Connect bearer token
Args:
refresh_token: A refresh token retrieved using the
``swh auth login`` command (see :ref:`swh-web-client-auth`
section in main documentation)
Raises:
swh.web.client.auth.AuthenticationError: if authentication fails
"""
now = datetime.now()
try:
self.oidc_profile = self.oidc_session.refresh(refresh_token)
assert self.oidc_profile
if 'expires_in' in self.oidc_profile:
expires_in = self.oidc_profile['expires_in']
expires_at = now + timedelta(seconds=expires_in)
self.oidc_profile['expires_at'] = expires_at
except Exception as e:
raise AuthenticationError(str(e))
if 'access_token' not in self.oidc_profile:
# JSON error response
raise AuthenticationError(self.oidc_profile)
......@@ -15,12 +15,13 @@ def web_api_mock(requests_mock):
headers = {}
if api_call == "snapshot/cabcc7d7bf639bbe1cc3b41989e1806618dd5764/":
# monkey patch the only URL that require a special response headers
# (to make the client insit and follow pagination)
# (to make the client init and follow pagination)
headers = {
"Link":
f"<{API_URL}/{api_call}?branches_count=1000&branches_from=refs/tags/v3.0-rc7>; rel=\"next\"" # NoQA: E501
}
requests_mock.get(f"{API_URL}/{api_call}", text=data, headers=headers)
return requests_mock
@pytest.fixture
......
......@@ -11,7 +11,7 @@ from swh.web.client.cli import auth
runner = CliRunner()
_oidc_profile = {
oidc_profile = {
'access_token': 'some-access-token',
'expires_in': 600,
'refresh_expires_in': 0,
......@@ -27,11 +27,11 @@ def test_auth_login(mocker):
mock_getpass.return_value = 'password'
mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession')
mock_login = mock_oidc_session.return_value.login
mock_login.return_value = _oidc_profile
mock_login.return_value = oidc_profile
result = runner.invoke(auth, ['login', 'username'], input='password\n')
assert result.exit_code == 0
assert json.loads(result.output) == _oidc_profile
assert json.loads(result.output) == oidc_profile
mock_login.side_effect = Exception('Auth error')
......@@ -43,14 +43,14 @@ def test_auth_refresh(mocker):
mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession')
mock_refresh = mock_oidc_session.return_value.refresh
mock_refresh.return_value = _oidc_profile
mock_refresh.return_value = oidc_profile
result = runner.invoke(auth, ['refresh', _oidc_profile['refresh_token']])
result = runner.invoke(auth, ['refresh', oidc_profile['refresh_token']])
assert result.exit_code == 0
assert json.loads(result.stdout) == _oidc_profile['access_token']
assert json.loads(result.stdout) == oidc_profile['access_token']
mock_refresh.side_effect = Exception('Auth error')
result = runner.invoke(auth, ['refresh', _oidc_profile['refresh_token']])
result = runner.invoke(auth, ['refresh', oidc_profile['refresh_token']])
assert result.exit_code == 1
......@@ -59,9 +59,9 @@ def test_auth_logout(mocker):
mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession')
mock_logout = mock_oidc_session.return_value.logout
result = runner.invoke(auth, ['logout', _oidc_profile['refresh_token']])
result = runner.invoke(auth, ['logout', oidc_profile['refresh_token']])
assert result.exit_code == 0
mock_logout.side_effect = Exception('Auth error')
result = runner.invoke(auth, ['logout', _oidc_profile['refresh_token']])
result = runner.invoke(auth, ['logout', oidc_profile['refresh_token']])
assert result.exit_code == 1
......@@ -3,10 +3,18 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from copy import copy
from datetime import datetime
from dateutil.parser import parse as parse_date
from unittest.mock import call, Mock
import pytest
from swh.web.client.auth import AuthenticationError
from swh.model.identifiers import parse_persistent_identifier as parse_pid
from .test_cli import oidc_profile
def test_get_content(web_api_client, web_api_mock):
pid = parse_pid("swh:1:cnt:fe95a46679d128ff167b7c55df5d02356c5a1ae1")
......@@ -102,3 +110,92 @@ def test_iter_snapshot(web_api_client, web_api_mock):
snp.update(partial)
assert len(snp) == 1391
def test_authenticate_success(web_api_client, web_api_mock):
rel_id = 'b9db10d00835e9a43e2eebef2db1d04d4ae82342'
url = f'{web_api_client.api_url}/release/{rel_id}/'
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.return_value = copy(oidc_profile)
access_token = oidc_profile['access_token']
refresh_token = 'user-refresh-token'
web_api_client.authenticate(refresh_token)
assert 'expires_at' in web_api_client.oidc_profile
pid = parse_pid(f'swh:1:rel:{rel_id}')
web_api_client.get(pid)
web_api_client.oidc_session.refresh.assert_called_once_with(refresh_token)
sent_request = web_api_mock._adapter.last_request
assert sent_request.url == url
assert 'Authorization' in sent_request.headers
assert sent_request.headers['Authorization'] == f'Bearer {access_token}'
def test_authenticate_refresh_token(web_api_client, web_api_mock):
rel_id = 'b9db10d00835e9a43e2eebef2db1d04d4ae82342'
url = f'{web_api_client.api_url}/release/{rel_id}/'
oidc_profile_cp = copy(oidc_profile)
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.return_value = oidc_profile_cp
refresh_token = 'user-refresh-token'
web_api_client.authenticate(refresh_token)
assert 'expires_at' in web_api_client.oidc_profile
# simulate access token expiration
web_api_client.oidc_profile['expires_at'] = datetime.now()
access_token = 'new-access-token'
oidc_profile_cp['access_token'] = access_token
pid = parse_pid(f'swh:1:rel:{rel_id}')
web_api_client.get(pid)
calls = [call(refresh_token), call(oidc_profile['refresh_token'])]
web_api_client.oidc_session.refresh.assert_has_calls(calls)
sent_request = web_api_mock._adapter.last_request
assert sent_request.url == url
assert 'Authorization' in sent_request.headers
assert sent_request.headers['Authorization'] == f'Bearer {access_token}'
def test_authenticate_failure(web_api_client, web_api_mock):
msg = 'Authentication error'
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.side_effect = Exception(msg)
refresh_token = 'user-refresh-token'
with pytest.raises(AuthenticationError) as e:
web_api_client.authenticate(refresh_token)
assert e.match(msg)
oidc_error_response = {
'error': 'invalid_grant',
'error_description': 'Invalid refresh token',
}
web_api_client.oidc_session.refresh.side_effect = None
web_api_client.oidc_session.refresh.return_value = oidc_error_response
with pytest.raises(AuthenticationError) as e:
web_api_client.authenticate(refresh_token)
assert e.match(repr(oidc_error_response))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment