diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,8 @@ +.. _swh-auth-cli: + +Command-line interface +====================== + +.. click:: swh.auth.cli:auth + :prog: swh auth + :nested: full diff --git a/docs/index.rst b/docs/index.rst --- a/docs/index.rst +++ b/docs/index.rst @@ -8,5 +8,6 @@ .. toctree:: :maxdepth: 2 + cli django /apidoc/swh.auth diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -55,10 +55,10 @@ "testing": parse_requirements("test"), }, include_package_data=True, - # entry_points=""" - # [swh.cli.subcommands] - # =swh..cli - # """, + entry_points=""" + [swh.cli.subcommands] + auth=swh.auth.cli + """, classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", diff --git a/swh/auth/cli.py b/swh/auth/cli.py --- a/swh/auth/cli.py +++ b/swh/auth/cli.py @@ -1,19 +1,111 @@ +# Copyright (C) 2021 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 + +# WARNING: do not import unnecessary things here to keep cli startup time under +# control + +import sys + import click +from click.core import Context -from swh.core.cli import CONTEXT_SETTINGS from swh.core.cli import swh as swh_cli_group +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) -@swh_cli_group.group(name="foo", context_settings=CONTEXT_SETTINGS) + +@swh_cli_group.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 foo_cli_group(ctx): - """Foo main command. +def auth(ctx: Context, oidc_server_url: str, realm_name: str, client_id: str): + """ + Software Heritage Authentication tools. + + This CLI eases the retrieval of a bearer token to authenticate + a user querying Software Heritage Web APIs. """ + from swh.auth.keycloak import KeycloakOpenIDConnect + ctx.ensure_object(dict) + ctx.obj["oidc_client"] = KeycloakOpenIDConnect( + oidc_server_url, realm_name, client_id + ) -@foo_cli_group.command() -@click.option("--bar", help="Something") + +@auth.command("generate-token") +@click.argument("username") @click.pass_context -def bar(ctx, bar): - """Do something.""" - click.echo("bar") +def generate_token(ctx: Context, username: str): + """ + Generate a new bearer token for a Web API authentication. + + Login with USERNAME, create a new OpenID Connect session and get + bearer token. + + Users will be prompted for their password, then the token will be printed + to standard output. + + The created OpenID Connect session is an offline one so the provided + token has a much longer expiration time than classical OIDC + sessions (usually several dozens of days). + """ + from getpass import getpass + + from swh.auth.keycloak import KeycloakError, keycloak_error_message + + password = getpass() + + try: + oidc_info = ctx.obj["oidc_client"].login( + username, password, scope="openid offline_access" + ) + print(oidc_info["refresh_token"]) + except KeycloakError as ke: + print(keycloak_error_message(ke)) + sys.exit(1) + + +@auth.command("revoke-token") +@click.argument("token") +@click.pass_context +def revoke_token(ctx: Context, token: str): + """ + Revoke a bearer token used for a Web API authentication. + + Use TOKEN to logout from an offline OpenID Connect session. + + The token is definitely revoked after that operation. + """ + from swh.auth.keycloak import KeycloakError, keycloak_error_message + + try: + ctx.obj["oidc_client"].logout(token) + print("Token successfully revoked.") + except KeycloakError as ke: + print(keycloak_error_message(ke)) + sys.exit(1) diff --git a/swh/auth/keycloak.py b/swh/auth/keycloak.py --- a/swh/auth/keycloak.py +++ b/swh/auth/keycloak.py @@ -110,7 +110,7 @@ ) def login( - self, username: str, password: str, **extra_params: str + self, username: str, password: str, scope: str = "openid", **extra_params: str ) -> Dict[str, Any]: """ Get OpenID Connect authentication tokens using Direct Access Grant flow. @@ -126,7 +126,7 @@ """ return self._keycloak.token( grant_type="password", - scope="openid", + scope=scope, username=username, password=password, **extra_params, @@ -233,9 +233,14 @@ """Transform a keycloak exception into an error message. """ - msg_dict = json.loads(keycloak_error.error_message.decode()) - error_msg = msg_dict["error"] - error_desc = msg_dict.get("error_description") - if error_desc: - error_msg = f"{error_msg}: {error_desc}" - return error_msg + try: + # keycloak error wrapped in a JSON document + msg_dict = json.loads(keycloak_error.error_message.decode()) + error_msg = msg_dict["error"] + error_desc = msg_dict.get("error_description") + if error_desc: + error_msg = f"{error_msg}: {error_desc}" + return error_msg + except Exception: + # fallback: return error message string + return keycloak_error.error_message diff --git a/swh/auth/tests/test_cli.py b/swh/auth/tests/test_cli.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/test_cli.py @@ -0,0 +1,93 @@ +# Copyright (C) 2020-2021 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 click.testing import CliRunner +import pytest + +from swh.auth.cli import auth +from swh.auth.tests.sample_data import OIDC_PROFILE + +runner = CliRunner() + + +@pytest.fixture() +def keycloak_oidc(keycloak_oidc, mocker): + def _keycloak_oidc(server_url, realm_name, client_id): + keycloak_oidc.server_url = server_url + keycloak_oidc.realm_name = realm_name + keycloak_oidc.client_id = client_id + return keycloak_oidc + + keycloak_oidc_client = mocker.patch("swh.auth.keycloak.KeycloakOpenIDConnect") + keycloak_oidc_client.side_effect = _keycloak_oidc + return keycloak_oidc + + +def _run_auth_command(command, keycloak_oidc, input=None): + server_url = "http://localhost:5080/auth" + realm_name = "realm-test" + client_id = "client-test" + result = runner.invoke( + auth, + [ + "--oidc-server-url", + server_url, + "--realm-name", + realm_name, + "--client-id", + client_id, + *command, + ], + input=input, + ) + assert keycloak_oidc.server_url == server_url + assert keycloak_oidc.realm_name == realm_name + assert keycloak_oidc.client_id == client_id + return result + + +@pytest.fixture +def user_credentials(): + return {"username": "foo", "password": "bar"} + + +def test_auth_generate_token_ok(keycloak_oidc, mocker, user_credentials): + mock_getpass = mocker.patch("getpass.getpass") + mock_getpass.return_value = user_credentials["password"] + + command = ["generate-token", user_credentials["username"]] + result = _run_auth_command( + command, keycloak_oidc, input=f"{user_credentials['password']}\n" + ) + assert result.exit_code == 0 + assert result.output[:-1] == OIDC_PROFILE["refresh_token"] + + +def test_auth_generate_token_error(keycloak_oidc, mocker, user_credentials): + keycloak_oidc.set_auth_success(False) + mock_getpass = mocker.patch("getpass.getpass") + mock_getpass.return_value = user_credentials["password"] + + command = ["generate-token", user_credentials["username"]] + result = _run_auth_command( + command, keycloak_oidc, input=f"{user_credentials['password']}\n" + ) + assert result.exit_code == 1 + assert result.output[:-1] == "invalid_grant: Invalid user credentials" + + +def test_auth_remove_token_ok(keycloak_oidc): + command = ["revoke-token", OIDC_PROFILE["refresh_token"]] + result = _run_auth_command(command, keycloak_oidc) + assert result.exit_code == 0 + assert result.output[:-1] == "Token successfully revoked." + + +def test_auth_remove_token_error(keycloak_oidc): + keycloak_oidc.set_auth_success(False) + command = ["revoke-token", OIDC_PROFILE["refresh_token"]] + result = _run_auth_command(command, keycloak_oidc) + assert result.exit_code == 1 + assert result.output[:-1] == "invalid_grant: Invalid user credentials" diff --git a/swh/auth/tests/test_keycloak.py b/swh/auth/tests/test_keycloak.py --- a/swh/auth/tests/test_keycloak.py +++ b/swh/auth/tests/test_keycloak.py @@ -173,3 +173,10 @@ actual_result = keycloak_error_message(exception) assert actual_result == expected_result + + +def test_auth_keycloak_error_message_string(): + """Conversion from KeycloakError to error message should work with detail or not""" + error_message = "Can't connect to server " + exception = KeycloakError(error_message=error_message) + assert keycloak_error_message(exception) == error_message