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] + authenticate=swh.web.client.cli:authenticate + ''', ) 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,111 @@ +# 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='authenticate', context_settings=CONTEXT_SETTINGS) +@click.option('--oidc-server-url', 'oidc_server_url', + default='https://auth.softwareheritage.org/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 authenticate(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.obj['oidc_session'] = OpenIDConnectSession( + oidc_server_url, realm_name, client_id) + pass + + +@authenticate.command('session-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() + + try: + resp_json = ctx.obj['oidc_session'].login(username, password) + print(json.dumps(resp_json)) + except Exception as e: + print(f'Error: {str(e)}') + + +@authenticate.command('session-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. + """ + try: + resp_json = ctx.obj['oidc_session'].refresh(refresh_token) + if 'access_token' in resp_json: + print(json.dumps(resp_json['access_token'])) + else: + # print oidc error + print(json.dumps(resp_json)) + except Exception as e: + print(f'Error: {str(e)}') + + +@authenticate.command('session-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. + """ + try: + ctx.obj['oidc_session'].logout(refresh_token) + print('Successfully logged out from OpenID Connect session') + except Exception as e: + print(f'Error: {str(e)}') 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,56 @@ +# 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 authenticate + +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-session-state', + 'scope': 'openid email profile offline_access', +} + + +def test_authenticate_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_oidc_session.return_value.login.return_value = _oidc_profile + + result = runner.invoke(authenticate, ['session-login', 'username'], obj={}) + assert result.exit_code == 0 + assert json.loads(result.output) == _oidc_profile + + +def test_authenticate_refresh(mocker): + + mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession') + mock_oidc_session.return_value.refresh.return_value = _oidc_profile + + result = runner.invoke(authenticate, + ['session-refresh', _oidc_profile['refresh_token']], + obj={}) + assert result.exit_code == 0 + assert json.loads(result.output) == _oidc_profile['access_token'] + + +def test_authenticate_logout(mocker): + + mocker.patch('swh.web.client.cli.OpenIDConnectSession') + + result = runner.invoke(authenticate, + ['session-logout', _oidc_profile['refresh_token']], + obj={}) + assert result.exit_code == 0