diff --git a/swh/auth/cli.py b/swh/auth/cli.py --- a/swh/auth/cli.py +++ b/swh/auth/cli.py @@ -6,7 +6,9 @@ # WARNING: do not import unnecessary things here to keep cli startup time under # control +import os import sys +from typing import Any, Dict import click from click.core import Context @@ -15,12 +17,24 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +# TODO (T1410): All generic config code should reside in swh.core.config +DEFAULT_CONFIG_PATH = os.environ.get( + "SWH_CONFIG_FILE", os.path.join(click.get_app_dir("swh"), "global.yml") +) + +DEFAULT_CONFIG: Dict[str, Any] = { + "oidc_server_url": "https://auth.softwareheritage.org/auth/", + "realm_name": "SoftwareHeritage", + "client_id": "swh-web", + "bearer_token": None, +} + @swh_cli_group.group(name="auth", context_settings=CONTEXT_SETTINGS) @click.option( "--oidc-server-url", "oidc_server_url", - default="https://auth.softwareheritage.org/auth/", + default=DEFAULT_CONFIG["oidc_server_url"], help=( "URL of OpenID Connect server (default to " '"https://auth.softwareheritage.org/auth/")' @@ -29,7 +43,7 @@ @click.option( "--realm-name", "realm_name", - default="SoftwareHeritage", + default=DEFAULT_CONFIG["realm_name"], help=( "Name of the OpenID Connect authentication realm " '(default to "SoftwareHeritage")' @@ -38,23 +52,73 @@ @click.option( "--client-id", "client_id", - default="swh-web", + default=DEFAULT_CONFIG["client_id"], help=("OpenID Connect client identifier in the realm " '(default to "swh-web")'), ) +@click.option( + "-C", + "--config-file", + default=None, + type=click.Path(exists=True, dir_okay=False, path_type=str), + help=f"Configuration file (default: {DEFAULT_CONFIG_PATH})", +) @click.pass_context -def auth(ctx: Context, oidc_server_url: str, realm_name: str, client_id: str): +def auth( + ctx: Context, + oidc_server_url: str, + realm_name: str, + client_id: str, + config_file: str, +): """ Software Heritage Authentication tools. This CLI eases the retrieval of a bearer token to authenticate a user querying Software Heritage Web APIs. """ + import logging + from pathlib import Path + + import yaml + from swh.auth.keycloak import KeycloakOpenIDConnect + from swh.core import config + + if not config_file: + config_file = DEFAULT_CONFIG_PATH + + # Missing configuration file + if not config.config_exists(config_file): + # if not Path(config_file).exists(): + click.echo(f"The Swh configuration file {config_file} does not exists.") + if click.confirm("Do you want to create it?"): + Path(config_file).touch() + Path(config_file).write_text("swh:\n") + with open(config_file, "w") as file: + yaml.dump({"swh": {"auth": DEFAULT_CONFIG}}, file) + msg = f"Swh configuration file {config_file} successfully created." + click.echo(click.style(msg, fg="green")) + else: + sys.exit(1) + + try: + conf = config.read_raw_config(config.config_basepath(config_file)) + if not conf: + raise ValueError(f"Cannot parse configuration file: {config_file}") + assert conf["swh"]["auth"] + conf = config.merge_configs(DEFAULT_CONFIG, conf["swh"]["auth"]) + except Exception: + logging.warning( + "Using default configuration (cannot load custom one)", exc_info=True + ) + conf = DEFAULT_CONFIG ctx.ensure_object(dict) ctx.obj["oidc_client"] = KeycloakOpenIDConnect( oidc_server_url, realm_name, client_id ) + ctx.obj["config_file"] = config_file + ctx.obj["config"] = conf @auth.command("generate-token") @@ -109,3 +173,65 @@ except KeycloakError as ke: print(keycloak_error_message(ke)) sys.exit(1) + + +@auth.command("set-token") +@click.argument("token", required=False) +@click.pass_context +def set_token(ctx: Context, token: str): + """ + Set a bearer token for an OIDC authentication. + + Users will be prompted for their token, then the token will be saved + to standard configuration file. + """ + from pathlib import Path + + import yaml + + from swh.auth.keycloak import KeycloakError, keycloak_error_message + + # Check if a token already exists in configuration file and inform the user + if ( + "bearer_token" in ctx.obj["config"] + and ctx.obj["config"]["bearer_token"] is not None + ): + if not click.confirm( + "A token entry already exists in your configuration file." + "\nDo you want to override it?" + ): + sys.exit(1) + + if not token: + raw_token = click.prompt(text="Fill or Paste your token") + else: + raw_token = token + + bearer_token = raw_token.strip() + + # Ensure the token is valid by getting user info + try: + oidc_client = ctx.obj["oidc_client"] + # userinfo endpoint needs the access_token + access_token = oidc_client.refresh_token(refresh_token=bearer_token)[ + "access_token" + ] + oidc_info = oidc_client.userinfo(access_token=access_token) + msg = ( + f"Token verification success for username {oidc_info['preferred_username']}" + ) + click.echo(click.style(msg, fg="green")) + except KeycloakError as ke: + msg = keycloak_error_message(ke) + click.echo(click.style(msg, fg="red")) + ctx.exit(1) + + # Write the new token into the file. + # TODO use ruamel.yaml to preserve comments in config file + ctx.obj["config"]["bearer_token"] = bearer_token + config_file_path = Path(ctx.obj["config_file"]) + config_file_path.write_text(yaml.safe_dump({"swh": {"auth": ctx.obj["config"]}})) + + msg = "Token successfully added to configuration file '%s'" + msg %= click.format_filename(str(config_file_path)) + click.echo(click.style(msg, fg="green")) diff --git a/swh/auth/tests/test_cli.py b/swh/auth/tests/test_cli.py --- a/swh/auth/tests/test_cli.py +++ b/swh/auth/tests/test_cli.py @@ -26,7 +26,7 @@ def _run_auth_command(command, keycloak_oidc, input=None): - server_url = "http://localhost:5080/auth" + server_url = "http://keycloak:8080/keycloak/auth/" realm_name = "realm-test" client_id = "client-test" result = runner.invoke(