Page MenuHomeSoftware Heritage

D2869.diff
No OneTemporary

D2869.diff

diff --git a/docs/index.rst b/docs/index.rst
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,6 +2,8 @@
.. include:: README.rst
+.. _swh-web-client-auth:
+
Authentication
--------------
@@ -81,6 +83,21 @@
$ 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.
diff --git a/swh/web/client/auth.py b/swh/web/client/auth.py
--- a/swh/web/client/auth.py
+++ b/swh/web/client/auth.py
@@ -13,6 +13,15 @@
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.
diff --git a/swh/web/client/client.py b/swh/web/client/client.py
--- a/swh/web/client/client.py
+++ b/swh/web/client/client.py
@@ -28,7 +28,8 @@
"""
-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 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 @@
"""
- 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 @@
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 @@
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 @@
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)
diff --git a/swh/web/client/tests/conftest.py b/swh/web/client/tests/conftest.py
--- a/swh/web/client/tests/conftest.py
+++ b/swh/web/client/tests/conftest.py
@@ -15,12 +15,13 @@
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
diff --git a/swh/web/client/tests/test_cli.py b/swh/web/client/tests/test_cli.py
--- a/swh/web/client/tests/test_cli.py
+++ b/swh/web/client/tests/test_cli.py
@@ -11,7 +11,7 @@
runner = CliRunner()
-_oidc_profile = {
+oidc_profile = {
'access_token': 'some-access-token',
'expires_in': 600,
'refresh_expires_in': 0,
@@ -27,11 +27,11 @@
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 @@
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 @@
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
diff --git a/swh/web/client/tests/test_web_api_client.py b/swh/web/client/tests/test_web_api_client.py
--- a/swh/web/client/tests/test_web_api_client.py
+++ b/swh/web/client/tests/test_web_api_client.py
@@ -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 @@
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))

File Metadata

Mime Type
text/plain
Expires
Wed, Dec 18, 4:29 AM (22 h, 30 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3216395

Event Timeline