diff --git a/requirements-test.txt b/requirements-test.txt --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest +pytest-mock requests_mock diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Add here external Python modules dependencies, one per line. Module names # should match https://pypi.python.org/pypi names. For the full spec or # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html +click python-dateutil requests vcversioner diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -65,4 +65,8 @@ 'Funding': 'https://www.softwareheritage.org/donate', 'Source': 'https://forge.softwareheritage.org/source/swh-web-client', }, + entry_points=''' + [swh.cli.subcommands] + authentication=swh.web.client.cli:authentication + ''', ) diff --git a/swh/web/client/auth.py b/swh/web/client/auth.py new file mode 100644 --- /dev/null +++ b/swh/web/client/auth.py @@ -0,0 +1,95 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Any, Dict +from urllib.parse import urljoin + +import requests + +SWH_OIDC_SERVER_URL = 'https://auth.softwareheritage.org/auth/' +SWH_REALM_NAME = 'SoftwareHeritage' +SWH_WEB_CLIENT_ID = 'swh-web' + + +class OpenIDConnectSession: + """ + Simple class wrapping requests sent to an OpenID Connect server. + + Args: + oidc_server_url: URL of OpenID Connect server + realm_name: name of the OpenID Connect authentication realm + client_id: OpenID Connect client identifier in the realm + """ + + def __init__(self, oidc_server_url: str = SWH_OIDC_SERVER_URL, + realm_name: str = SWH_REALM_NAME, + client_id: str = SWH_WEB_CLIENT_ID): + realm_url = urljoin(oidc_server_url, f'realms/{realm_name}/') + self.client_id = client_id + self.token_url = urljoin(realm_url, + 'protocol/openid-connect/token/') + self.logout_url = urljoin(realm_url, + 'protocol/openid-connect/logout/') + + def login(self, username: str, password: str) -> Dict[str, Any]: + """ + Login and create new offline OpenID Connect session. + + Args: + username: an existing username in the realm + password: password associated to username + + 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': 'password', + 'client_id': self.client_id, + 'scope': 'openid offline_access', + 'username': username, + 'password': password, + }, + ).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): + """ + Logout from an offline OpenID Connect session and invalidate + previously emitted tokens. + + Args: + refresh_token: a refresh token retrieved after login + """ + requests.post( + url=self.logout_url, + data={ + 'client_id': self.client_id, + 'scope': 'openid', + 'refresh_token': refresh_token, + }, + ) diff --git a/swh/web/client/cli.py b/swh/web/client/cli.py new file mode 100644 --- /dev/null +++ b/swh/web/client/cli.py @@ -0,0 +1,104 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from getpass import getpass +import json + +import click +from click.core import Context + +from swh.web.client.auth import OpenIDConnectSession + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@click.group(name='authentication', context_settings=CONTEXT_SETTINGS) +@click.option('--oidc-server-url', 'oidc_server_url', + default='http://localhost:8080/auth/', + help=('URL of OpenID Connect server (default to ' + '"https://auth.softwareheritage.org/auth/")')) +@click.option('--realm-name', 'realm_name', + default='SoftwareHeritage', + help=('Name of the OpenID Connect authentication realm ' + '(default to "SoftwareHeritage")')) +@click.option('--client-id', 'client_id', + default='swh-web', + help=('OpenID Connect client identifier in the realm ' + '(default to "swh-web")')) +@click.pass_context +def authentication(ctx: Context, oidc_server_url: str, + realm_name: str, client_id: str): + """ + Authenticate Software Heritage users with OpenID Connect. + + This CLI tool eases the retrieval of bearer tokens to authenticate + a user querying the Software Heritage Web API. + """ + ctx.ensure_object(dict) + ctx.obj['oidc_session'] = OpenIDConnectSession( + oidc_server_url, realm_name, client_id) + pass + + +@authentication.command('login') +@click.argument('username') +@click.pass_context +def login(ctx: Context, username: str): + """ + 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. + + 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. + + The created OpenID Connect session is an offline one so the provided + refresh token has a much longer expiration time than classical OIDC + sessions (usually several dozens of days). + """ + password = getpass() + + resp_json = ctx.obj['oidc_session'].login(username, password) + print(json.dumps(resp_json, indent=4, sort_keys=True)) + + +@authentication.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. + """ + resp_json = ctx.obj['oidc_session'].refresh(refresh_token) + if 'access_token' in resp_json: + print(json.dumps(resp_json['access_token'], + indent=4, sort_keys=True)) + else: + # print oidc error + print(json.dumps(resp_json, indent=4, sort_keys=True)) + + +@authentication.command('logout') +@click.argument('refresh_token') +@click.pass_context +def logout(ctx: Context, refresh_token: str): + """ + Logout from an offline OpenID Connect session. + + Use REFRESH_TOKEN to logout from an offline OpenID Connect session. + + Access and refresh tokens are no more usable after that operation. + """ + ctx.obj['oidc_session'].logout(refresh_token) + print('Successfully logged out from OpenID Connect session') diff --git a/swh/web/client/tests/test_cli.py b/swh/web/client/tests/test_cli.py new file mode 100644 --- /dev/null +++ b/swh/web/client/tests/test_cli.py @@ -0,0 +1,73 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# 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 authentication + +runner = CliRunner() + +_oidc_profile = { + 'access_token': 'some-access-token', + 'expires_in': 600, + 'refresh_expires_in': 0, + 'refresh_token': 'some-refresh-token', + 'token_type': 'bearer', + 'session_state': 'some-state', + 'scope': 'openid email profile offline_access', +} + + +def test_authentication_login(mocker): + mock_getpass = mocker.patch('swh.web.client.cli.getpass') + 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 + + result = runner.invoke(authentication, ['login', 'username'], + input='password\n') + assert result.exit_code == 0 + assert json.loads(result.output) == _oidc_profile + + mock_login.side_effect = Exception('Auth error') + + result = runner.invoke(authentication, ['login', 'username'], + input='password\n') + assert result.exit_code == 1 + + +def test_authentication_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(authentication, + ['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(authentication, + ['refresh', _oidc_profile['refresh_token']]) + assert result.exit_code == 1 + + +def test_authentication_logout(mocker): + + mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession') + mock_logout = mock_oidc_session.return_value.logout + + result = runner.invoke(authentication, + ['logout', _oidc_profile['refresh_token']]) + assert result.exit_code == 0 + + mock_logout.side_effect = Exception('Auth error') + result = runner.invoke(authentication, + ['logout', _oidc_profile['refresh_token']]) + assert result.exit_code == 1