diff --git a/conftest.py b/conftest.py new file mode 100644 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["swh.auth.pytest_plugin"] diff --git a/docs/index.rst b/docs/index.rst --- a/docs/index.rst +++ b/docs/index.rst @@ -17,13 +17,13 @@ .. code-block:: text - $ swh web auth - Usage: swh web auth [OPTIONS] COMMAND [ARGS]... + $ swh auth + Usage: swh auth [OPTIONS] COMMAND [ARGS]... - Authenticate Software Heritage users with OpenID Connect. + Software Heritage Authentication tools. - This CLI tool eases the retrieval of bearer tokens to authenticate a user - querying the Software Heritage Web API. + This CLI eases the retrieval of a bearer token to authenticate a user + querying Software Heritage Web APIs. Options: --oidc-server-url TEXT URL of OpenID Connect server (default to @@ -38,10 +38,8 @@ -h, --help Show this message and exit. Commands: - generate-token Generate a new bearer token for Web API authentication. - login Alias for 'generate-token' - logout Alias for 'revoke-token' - revoke-token Revoke a bearer token used for Web API authentication. + generate-token Generate a new bearer token for a Web API authentication. + revoke-token Revoke a bearer token used for a Web API authentication. In order to get your tokens, you need to use the ``generate-token`` subcommand of the CLI tool by passing your username as argument. You will be prompted @@ -50,7 +48,7 @@ .. code-block:: text - $ swh web auth generate-token + $ swh auth --client-id swh-web generate-token Password: eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNjMzMD... @@ -81,7 +79,7 @@ .. code-block:: text - $ swh web auth revoke-token $REFRESH_TOKEN + $ swh auth --client-id swh-web revoke-token $REFRESH_TOKEN Token successfully revoked. API Reference diff --git a/requirements-swh.txt b/requirements-swh.txt --- a/requirements-swh.txt +++ b/requirements-swh.txt @@ -1,3 +1,4 @@ # Add here internal Software Heritage dependencies, one per line. +swh.auth >= 0.6 swh.core >= 0.3 swh.model diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -69,6 +69,6 @@ }, entry_points=""" [swh.cli.subcommands] - auth=swh.web.client.cli + web=swh.web.client.cli """, ) diff --git a/swh/web/client/auth.py b/swh/web/client/auth.py deleted file mode 100644 --- a/swh/web/client/auth.py +++ /dev/null @@ -1,84 +0,0 @@ -# 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 AuthenticationError(Exception): - """Authentication related error. - - Example: A bearer token has been revoked. - - """ - - pass - - -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: - The OpenID Connect session info - """ - 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 logout(self, token: str): - """ - Logout from an offline OpenID Connect session and invalidate - previously emitted tokens. - - Args: - token: a bearer token retrieved after login - """ - requests.post( - url=self.logout_url, - data={ - "client_id": self.client_id, - "scope": "openid", - "refresh_token": token, - }, - ) diff --git a/swh/web/client/cli.py b/swh/web/client/cli.py --- a/swh/web/client/cli.py +++ b/swh/web/client/cli.py @@ -11,6 +11,9 @@ import click from click.core import Context +from swh.auth.cli import auth as auth_cli +from swh.auth.cli import generate_token as auth_generate_token +from swh.auth.cli import revoke_token as auth_revoke_token from swh.core.cli import swh as swh_cli_group CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -178,48 +181,25 @@ print(json.dumps(processed_origins)) -@web.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")'), -) +def _forward_context(ctx: Context, *args, **kwargs): + ctx.forward(*args, **kwargs) + + +@web.group(name="auth", context_settings=CONTEXT_SETTINGS, deprecated=True) @click.pass_context -def auth(ctx: Context, oidc_server_url: str, realm_name: str, client_id: str): +def auth(ctx: Context): """ Authenticate Software Heritage users with OpenID Connect. This CLI tool eases the retrieval of a bearer token to authenticate a user querying the Software Heritage Web API. - """ - from swh.web.client.auth import OpenIDConnectSession - ctx.ensure_object(dict) - ctx.obj["oidc_session"] = OpenIDConnectSession( - oidc_server_url, realm_name, client_id - ) + That command group is deprecated, use ``swh auth`` instead. + """ + _forward_context(ctx, auth_cli, client_id="swh-web") -@auth.command("generate-token") +@auth.command("generate-token", deprecated=True) @click.argument("username") @click.pass_context def generate_token(ctx: Context, username: str): @@ -236,28 +216,10 @@ token has a much longer expiration time than classical OIDC sessions (usually several dozens of days). """ - from getpass import getpass - - password = getpass() - - oidc_info = ctx.obj["oidc_session"].login(username, password) - if "refresh_token" in oidc_info: - print(oidc_info["refresh_token"]) - else: - print(oidc_info) + _forward_context(ctx, auth_generate_token, username=username) -@auth.command("login", deprecated=True) -@click.argument("username") -@click.pass_context -def login(ctx: Context, username: str): - """ - Alias for 'generate-token' - """ - ctx.forward(generate_token) - - -@auth.command("revoke-token") +@auth.command("revoke-token", deprecated=True) @click.argument("token") @click.pass_context def revoke_token(ctx: Context, token: str): @@ -268,15 +230,4 @@ The token is definitely revoked after that operation. """ - ctx.obj["oidc_session"].logout(token) - print("Token successfully revoked.") - - -@auth.command("logout", deprecated=True) -@click.argument("token") -@click.pass_context -def logout(ctx: Context, token: str): - """ - Alias for 'revoke-token' - """ - ctx.forward(revoke_token) + _forward_context(ctx, auth_revoke_token, token=token) diff --git a/swh/web/client/tests/test_cli.py b/swh/web/client/tests/test_cli.py --- a/swh/web/client/tests/test_cli.py +++ b/swh/web/client/tests/test_cli.py @@ -8,53 +8,37 @@ from click.testing import CliRunner -from swh.web.client.cli import auth, web +from swh.web.client.cli import auth_cli, auth_generate_token, auth_revoke_token, web 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_generate_token(mocker): - mock_getpass = mocker.patch("getpass.getpass") - mock_getpass.return_value = "password" - mock_oidc_session = mocker.patch("swh.web.client.auth.OpenIDConnectSession") - mock_login = mock_oidc_session.return_value.login - mock_login.return_value = oidc_profile - - for command in ("generate-token", "login"): - mock_login.side_effect = None - result = runner.invoke(auth, [command, "username"], input="password\n") - assert result.exit_code == 0 - assert oidc_profile["refresh_token"] in result.output - - mock_login.side_effect = Exception("Auth error") - - result = runner.invoke(auth, [command, "username"], input="password\n") - assert result.exit_code == 1 + forward_context = mocker.patch("swh.web.client.cli._forward_context") + runner.invoke(web, ["auth", "generate-token", "username"]) + assert forward_context.call_count == 2 + ctx = forward_context.call_args_list[0][0][0] + ctx2 = forward_context.call_args_list[1][0][0] + forward_context.assert_has_calls( + [ + mocker.call(ctx, auth_cli, client_id="swh-web"), + mocker.call(ctx2, auth_generate_token, username="username"), + ] + ) def test_auth_revoke_token(mocker): - - mock_oidc_session = mocker.patch("swh.web.client.auth.OpenIDConnectSession") - mock_logout = mock_oidc_session.return_value.logout - - for command in ("revoke-token", "logout"): - mock_logout.side_effect = None - result = runner.invoke(auth, [command, oidc_profile["refresh_token"]]) - assert result.exit_code == 0 - - mock_logout.side_effect = Exception("Auth error") - result = runner.invoke(auth, [command, oidc_profile["refresh_token"]]) - assert result.exit_code == 1 + forward_context = mocker.patch("swh.web.client.cli._forward_context") + runner.invoke(web, ["auth", "revoke-token", "token"]) + assert forward_context.call_count == 2 + ctx = forward_context.call_args_list[0][0][0] + ctx2 = forward_context.call_args_list[1][0][0] + forward_context.assert_has_calls( + [ + mocker.call(ctx, auth_cli, client_id="swh-web"), + mocker.call(ctx2, auth_revoke_token, token="token"), + ] + ) def test_save_code_now_through_cli(mocker, web_api_mock, tmp_path, cli_config_path):