Page MenuHomeSoftware Heritage

D2861.id10193.diff
No OneTemporary

D2861.id10193.diff

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

File Metadata

Mime Type
text/plain
Expires
Sun, Aug 17, 7:51 PM (1 w, 5 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3226552

Event Timeline