Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9345899
D5954.id21401.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
9 KB
Subscribers
None
D5954.id21401.diff
View Options
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
Details
Attached
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
Attached To
D5954: cli: Add commands to generate and revoke bearer tokens
Event Timeline
Log In to Comment