Page MenuHomeSoftware Heritage

D2861.id10232.diff
No OneTemporary

D2861.id10232.diff

diff --git a/docs/index.rst b/docs/index.rst
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,6 +2,93 @@
.. include:: README.rst
+Authentication
+--------------
+
+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
+allow to lift API rate limiting depending on your permissions.
+
+To get these tokens, a dedicated CLI tool is made available when installing
+``swh-web-client``:
+
+.. code-block:: text
+
+ $ swh auth
+ Usage: swh auth [OPTIONS] COMMAND [ARGS]...
+
+ 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.
+
+ Options:
+ --oidc-server-url TEXT URL of OpenID Connect server (default to
+ "https://auth.softwareheritage.org/auth/")
+ --realm-name TEXT Name of the OpenID Connect authentication realm
+ (default to "SoftwareHeritage")
+ --client-id TEXT OpenID Connect client identifier in the realm
+ (default to "swh-web")
+ -h, --help Show this message and exit.
+
+ 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.
+
+.. code-block:: text
+
+ $ swh auth login <username>
+ 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
+variable, you can perform an authenticated call the following way using ``curl``:
+
+.. code-block:: text
+
+ $ curl -H "Authorization: Bearer ${TOKEN}" http://localhost:5004/api/1/<endpoint>
+
+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
+ "......."
+
+It is also possible to ``logout`` from the authenticated OpenID Connect session
+which invalidates all previously emitted tokens.
+
+
+.. code-block:: text
+
+ $ swh auth logout $REFRESH_TOKEN
+ Successfully logged out from OpenID Connect session
API Reference
-------------
@@ -10,3 +97,6 @@
:maxdepth: 2
/apidoc/swh.web.client
+
+.. _Software Heritage Identity Provider:
+ https://auth.softwareheritage.org/auth/realms/SoftwareHeritage/account/
\ No newline at end of file
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]
+ auth=swh.web.client.cli:auth
+ ''',
)
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,105 @@
+# 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"])
+
+
+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', '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 auth(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)
+
+
+@auth.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()
+
+ 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'])
+ else:
+ # print oidc error
+ _output_json(oidc_profile)
+
+
+@auth.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,67 @@
+# 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 auth
+
+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_auth_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(auth, ['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(auth, ['login', 'username'], input='password\n')
+ 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')
+ mock_logout = mock_oidc_session.return_value.logout
+
+ 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']])
+ assert result.exit_code == 1

File Metadata

Mime Type
text/plain
Expires
Sun, Aug 17, 7:59 PM (5 d, 8 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3221634

Event Timeline