Page MenuHomeSoftware Heritage

D5954.id21401.diff
No OneTemporary

D5954.id21401.diff

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]
- # <cli-name>=swh.<module>.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

File Metadata

Mime Type
text/plain
Expires
Thu, Jul 3, 3:35 PM (1 w, 3 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3218983

Event Timeline