diff --git a/docs/index.rst b/docs/index.rst --- a/docs/index.rst +++ b/docs/index.rst @@ -9,10 +9,10 @@ If you have a user account registered on `Software Heritage Identity Provider`_, it is possible to authenticate requests made to the Web APIs through the use of -OpenID Connect bearer tokens. Sending authenticated requests can notably +a OpenID Connect bearer token. Sending authenticated requests can notably allow to lift API rate limiting depending on your permissions. -To get these tokens, a dedicated CLI tool is made available when installing +To get this token, a dedicated CLI tool is made available when installing ``swh-web-client``: .. code-block:: text @@ -37,70 +37,41 @@ Commands: login Login and create new offline OpenID Connect session. logout Logout from an offline OpenID Connect session. - refresh Refresh an offline OpenID Connect session. In order to get your tokens, you need to use the ``login`` subcommand of that CLI tool by passing your username as argument. You will be prompted for your password and if the authentication succeeds a new OpenID Connect -session will be created and tokens will be dumped in JSON format to standard -output. +session will be created and tokens will be dumped to standard output. .. code-block:: text $ swh auth login Password: - { - "access_token": ".......", - "expires_in": 600, - "refresh_expires_in": 0, - "refresh_token": ".......", - "token_type": "bearer", - "id_token": ".......", - "not-before-policy": 1584551170, - "session_state": "c14e1b7b-8263-4852-bd1c-adc7bc12a136", - "scope": "openid email profile offline_access" - } - -To authenticate yourself, you need to send the ``access_token`` value in -request headers when querying the Web APIs. -Considering you have stored the ``access_token`` value in a TOKEN environment + eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNjMzMD... + +To authenticate yourself, you need to send that token value in request headers +when querying the Web API. +Considering you have stored that token value in a TOKEN environment variable, you can perform an authenticated call the following way using ``curl``: .. code-block:: text $ curl -H "Authorization: Bearer ${TOKEN}" https://archive.softwareheritage.org/api/1/ -The access token has a short living period (usually ten minutes) and must be -renewed on a regular basis by passing the ``refresh_token`` value as argument -of the ``refresh`` subcommand of the CLI tool. The new access token will be -dumped in JSON format to standard output. Note that the refresh token has a -much longer living period (usually several dozens of days) so you can use -it anytime while it is valid to get an access token without having to login -again. - -.. code-block:: text - - $ 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:: +class, you can activate authentication by using the following code snippet:: from swh.web.client import WebAPIClient - REFRESH_TOKEN = '.......' # Use "swh auth login" command to get it + TOKEN = '.......' # Use "swh auth login" command to get it - client = WebAPIClient() - client.authenticate(REFRESH_TOKEN) + client = WebAPIClient(bearer_token=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. - +which definitely revokes the token. .. code-block:: text 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 @@ -16,7 +16,7 @@ class AuthenticationError(Exception): """Authentication related error. - Example: A bearer token has expired. + Example: A bearer token has been revoked. """ @@ -53,8 +53,7 @@ password: password associated to username Returns: - a dict filled with OpenID Connect profile info, notably access - and refresh tokens for API authentication. + The OpenID Connect session info """ return requests.post( url=self.token_url, @@ -67,40 +66,19 @@ }, ).json() - def refresh(self, refresh_token: str) -> Dict[str, Any]: - """ - Refresh an offline OpenID Connect session to get new access token. - - Args: - refresh_token: a refresh token retrieved after login - - Returns: - a dict filled with OpenID Connect profile info, notably access - and refresh tokens for API authentication. - """ - return requests.post( - url=self.token_url, - data={ - "grant_type": "refresh_token", - "client_id": self.client_id, - "scope": "openid", - "refresh_token": refresh_token, - }, - ).json() - - def logout(self, refresh_token: str): + def logout(self, token: str): """ Logout from an offline OpenID Connect session and invalidate previously emitted tokens. Args: - refresh_token: a refresh token retrieved after login + token: a bearer token retrieved after login """ requests.post( url=self.logout_url, data={ "client_id": self.client_id, "scope": "openid", - "refresh_token": refresh_token, + "refresh_token": token, }, ) diff --git a/swh/web/client/cli.py b/swh/web/client/cli.py --- a/swh/web/client/cli.py +++ b/swh/web/client/cli.py @@ -4,7 +4,6 @@ # See top-level LICENSE file for more information from getpass import getpass -import json import click from click.core import Context @@ -14,10 +13,6 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) -def _output_json(obj): - print(json.dumps(obj, indent=4, sort_keys=True)) - - @click.group(name="auth", context_settings=CONTEXT_SETTINGS) @click.option( "--oidc-server-url", @@ -48,7 +43,7 @@ """ Authenticate Software Heritage users with OpenID Connect. - This CLI tool eases the retrieval of bearer tokens to authenticate + This CLI tool eases the retrieval of a bearer token to authenticate a user querying the Software Heritage Web API. """ ctx.ensure_object(dict) @@ -65,54 +60,34 @@ Login and create new offline OpenID Connect session. Login with USERNAME, create a new OpenID Connect session and get - access and refresh tokens. - - User will be prompted for his password and tokens will be printed in - JSON format to standard output. + bearer token. - When its access token has expired, user can request a new one using the - session-refresh command of that CLI tool without having to authenticate - using a password again. + User will be prompted for his password and tokens will be printed + to standard output. The created OpenID Connect session is an offline one so the provided - refresh token has a much longer expiration time than classical OIDC + token has a much longer expiration time than classical OIDC sessions (usually several dozens of days). """ password = getpass() - oidc_profile = ctx.obj["oidc_session"].login(username, password) - _output_json(oidc_profile) - - -@auth.command("refresh") -@click.argument("refresh_token") -@click.pass_context -def refresh(ctx: Context, refresh_token: str): - """ - Refresh an offline OpenID Connect session. - - Get a new access token from REFRESH_TOKEN when previous one expired. - - New access token will be printed in JSON format to standard output. - """ - oidc_profile = ctx.obj["oidc_session"].refresh(refresh_token) - if "access_token" in oidc_profile: - _output_json(oidc_profile["access_token"]) + oidc_info = ctx.obj["oidc_session"].login(username, password) + if "refresh_token" in oidc_info: + print(oidc_info["refresh_token"]) else: - # print oidc error - _output_json(oidc_profile) + print(oidc_info) @auth.command("logout") -@click.argument("refresh_token") +@click.argument("token") @click.pass_context -def logout(ctx: Context, refresh_token: str): +def logout(ctx: Context, token: str): """ Logout from an offline OpenID Connect session. - Use REFRESH_TOKEN to logout from an offline OpenID Connect session. + Use TOKEN to logout from an offline OpenID Connect session. - Access and refresh tokens are no more usable after that operation. + The token is definitely revoked after that operation. """ - ctx.obj["oidc_session"].logout(refresh_token) + ctx.obj["oidc_session"].logout(token) print("Successfully logged out from OpenID Connect session") 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,6 @@ """ -from datetime import datetime, timedelta from typing import Any, Callable, Dict, Generator, List, Optional, Union from urllib.parse import urlparse @@ -39,8 +38,6 @@ 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] ORIGIN_VISIT = "origin_visit" @@ -107,9 +104,9 @@ elif obj_type == CONTENT: pass # nothing to do for contents elif obj_type == ORIGIN_VISIT: - data['date'] = to_date(data['date']) - if data['snapshot'] is not None: - data['snapshot'] = to_pid(SNAPSHOT, data['snapshot']) + data["date"] = to_date(data["date"]) + if data["snapshot"] is not None: + data["snapshot"] = to_pid(SNAPSHOT, data["snapshot"]) else: raise ValueError(f"invalid object type: {obj_type}") @@ -125,8 +122,8 @@ def __init__( self, - api_url="https://archive.softwareheritage.org/api/1", - auth_url=SWH_OIDC_SERVER_URL, + api_url: str = "https://archive.softwareheritage.org/api/1", + bearer_token: Optional[str] = None, ): """Create a client for the Software Heritage Web API @@ -135,15 +132,14 @@ Args: api_url: base URL for API calls (default: "https://archive.softwareheritage.org/api/1") - + bearer_token: optional bearer token to do authenticated API calls """ api_url = api_url.rstrip("/") u = urlparse(api_url) 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.bearer_token = bearer_token self._getters: Dict[str, Callable[[PIDish], Any]] = { CONTENT: self.content, @@ -175,13 +171,8 @@ 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 self.bearer_token is not None: + headers = {"Authorization": f"Bearer {self.bearer_token}"} if http_method == "get": r = requests.get(url, **req_args, headers=headers) @@ -336,11 +327,13 @@ else: done = True - def visits(self, - origin: str, - per_page: Optional[int] = None, - last_visit: Optional[int] = None, - **req_args) -> Generator[Dict[str, Any], None, None]: + def visits( + self, + origin: str, + per_page: Optional[int] = None, + last_visit: Optional[int] = None, + **req_args, + ) -> Generator[Dict[str, Any], None, None]: """List visits of an origin Args: @@ -365,14 +358,14 @@ if per_page is not None: params.append(("per_page", per_page)) - query = f'origin/{origin}/visits/' + query = f"origin/{origin}/visits/" while not done: - r = self._call(query, http_method='get', params=params, **req_args) + r = self._call(query, http_method="get", params=params, **req_args) yield from [typify(v, ORIGIN_VISIT) for v in r.json()] - if 'next' in r.links and 'url' in r.links['next']: + if "next" in r.links and "url" in r.links["next"]: params = [] - query = r.links['next']['url'] + query = r.links["next"]["url"] else: done = True @@ -480,29 +473,3 @@ 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/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 @@ -3,8 +3,6 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -import json - from click.testing import CliRunner from swh.web.client.cli import auth @@ -31,7 +29,7 @@ result = runner.invoke(auth, ["login", "username"], input="password\n") assert result.exit_code == 0 - assert json.loads(result.output) == oidc_profile + assert result.output[:-1] == oidc_profile["refresh_token"] mock_login.side_effect = Exception("Auth error") @@ -39,21 +37,6 @@ assert result.exit_code == 1 -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 - - result = runner.invoke(auth, ["refresh", oidc_profile["refresh_token"]]) - assert result.exit_code == 0 - 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"]]) - assert result.exit_code == 1 - - def test_auth_logout(mocker): mock_oidc_session = mocker.patch("swh.web.client.cli.OpenIDConnectSession") 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,18 +3,10 @@ # 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") @@ -118,105 +110,36 @@ 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): +def test_authentication(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 + web_api_client.bearer_token = refresh_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)) + assert sent_request.headers["Authorization"] == f"Bearer {refresh_token}" def test_get_visits(web_api_client, web_api_mock): - obj = web_api_client.visits('https://github.com/NixOS/nixpkgs', - last_visit=50, - per_page=10) + obj = web_api_client.visits( + "https://github.com/NixOS/nixpkgs", last_visit=50, per_page=10 + ) visits = [v for v in obj] assert len(visits) == 20 - timestamp = parse_date('2018-07-31 04:34:23.298931+00:00') - assert visits[0]['date'] == timestamp + timestamp = parse_date("2018-07-31 04:34:23.298931+00:00") + assert visits[0]["date"] == timestamp assert visits[0]["snapshot"] is None - snapshot_pid = 'swh:1:snp:456550ea74af4e2eecaa406629efaaf0b9b5f976' + snapshot_pid = "swh:1:snp:456550ea74af4e2eecaa406629efaaf0b9b5f976" assert visits[7]["snapshot"] == parse_pid(snapshot_pid)