Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F7123241
D2869.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
11 KB
Subscribers
None
D2869.diff
View Options
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
Details
Attached
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
Attached To
D2869: client: Add OpenID Connect bearer token authentication
Event Timeline
Log In to Comment