Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9696392
D2861.id10232.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
14 KB
Subscribers
None
D2861.id10232.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Sun, Aug 17, 7:59 PM (5 d, 13 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3221634
Attached To
D2861: cli: Add auth command group
Event Timeline
Log In to Comment