diff --git a/swh/auth/cli.py b/swh/auth/cli.py index 2b282ad7293efa23d8732b0738e6b61dca307df8..dbf4c3aba48a475ca87605fabefae6661916171c 100644 --- a/swh/auth/cli.py +++ b/swh/auth/cli.py @@ -19,53 +19,59 @@ 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") + "SWH_AUTH_CONFIG_FILE", os.path.join(click.get_app_dir("swh"), "auth.yml") ) +# Keycloak OpenID Connect defaults DEFAULT_CONFIG: Dict[str, Any] = { - "oidc_server_url": "https://auth.softwareheritage.org/auth/", - "realm_name": "SoftwareHeritage", - "client_id": "swh-web", - "bearer_token": None, + "keycloak": { + "server_url": "https://auth.softwareheritage.org/auth/", + "realm_name": "SoftwareHeritage", + "client_id": "swh-web", + } } @swh_cli_group.group(name="auth", context_settings=CONTEXT_SETTINGS) @click.option( "--oidc-server-url", - "oidc_server_url", - default=DEFAULT_CONFIG["oidc_server_url"], + "--server-url", + "server_url", + default=f"{DEFAULT_CONFIG['keycloak']['server_url']}", help=( "URL of OpenID Connect server (default to " - '"https://auth.softwareheritage.org/auth/")' + f"\"{DEFAULT_CONFIG['keycloak']['server_url']}\")" ), ) @click.option( "--realm-name", "realm_name", - default=DEFAULT_CONFIG["realm_name"], + default=f"{DEFAULT_CONFIG['keycloak']['realm_name']}", help=( "Name of the OpenID Connect authentication realm " - '(default to "SoftwareHeritage")' + f"(default to \"{DEFAULT_CONFIG['keycloak']['realm_name']}\")" ), ) @click.option( "--client-id", "client_id", - default=DEFAULT_CONFIG["client_id"], - help=("OpenID Connect client identifier in the realm " '(default to "swh-web")'), + default=f"{DEFAULT_CONFIG['keycloak']['client_id']}", + help=( + "OpenID Connect client identifier in the realm " + f"(default to \"{DEFAULT_CONFIG['keycloak']['client_id']}\")" + ), ) @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})", + help=f"Path to authentication configuration file (default: {DEFAULT_CONFIG_PATH})", ) @click.pass_context def auth( ctx: Context, - oidc_server_url: str, + server_url: str, realm_name: str, client_id: str, config_file: str, @@ -76,49 +82,41 @@ def auth( 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: + # Env var takes precedence on params + # Params takes precedence on "auth.yml" configuration file + # Configuration file takes precedence on default auth config values + # Set auth config to default values + cfg = DEFAULT_CONFIG + + # Merge with default auth config file + default_cfg_from_file = config.load_named_config("auth", global_conf=False) + cfg = config.merge_configs(cfg, default_cfg_from_file) + # Merge with user config file if any + if config_file: + user_cfg_from_file = config.read_raw_config(config_file) + cfg = config.merge_configs(cfg, user_cfg_from_file) + else: config_file = DEFAULT_CONFIG_PATH + # Merge with params if any (params load env var too) + ctx.ensure_object(dict) + params = {} + for key in DEFAULT_CONFIG["keycloak"].keys(): + if key in ctx.params: + params[key] = ctx.params[key] + if params: + cfg = config.merge_configs(cfg, {"keycloak": params}) - # 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 + assert "keycloak" in cfg - 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 + ctx.obj["keycloak"] = cfg["keycloak"] + + # Instantiate an OpenId connect client from keycloak auth configuration + # The 'keycloak' key is mandatory + ctx.obj["oidc_client"] = KeycloakOpenIDConnect.from_config(keycloak=cfg["keycloak"]) @auth.command("generate-token") @@ -157,6 +155,7 @@ def generate_token(ctx: Context, username: str, password): username, password, scope="openid offline_access" ) print(oidc_info["refresh_token"]) + return oidc_info["refresh_token"] except KeycloakError as ke: print(keycloak_error_message(ke)) sys.exit(1) @@ -183,15 +182,28 @@ def revoke_token(ctx: Context, token: str): sys.exit(1) -@auth.command("set-token") -@click.argument("token", required=False) +@auth.command("config") +@click.option( + "--username", + "username", + default=None, + help=("OpenID username"), +) +@click.option( + "--token", + "token", + default=None, + help=( + "A valid OpenId connect token to authenticate to " + f"\"{DEFAULT_CONFIG['keycloak']['server_url']}\"" + ), +) @click.pass_context -def set_token(ctx: Context, token: str): - """ - Set a bearer token for an OIDC authentication. +def auth_config(ctx: Context, username: str, token: str): + """Guided authentication configuration for Software Heritage web services - Users will be prompted for their token, then the token will be saved - to standard configuration file. + If you do not already have an account, create one at + "https://archive.softwareheritage.org/" """ from pathlib import Path @@ -199,29 +211,40 @@ def set_token(ctx: Context, token: str): 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: + assert "oidc_client" in ctx.obj + oidc_client = ctx.obj["oidc_client"] + + # params > config + # Ensure we get a token + raw_token: str = "" + + if token: + # Verify the token is valid raw_token = token + elif "token" in ctx.obj["keycloak"] and ctx.obj["keycloak"]["token"]: + # A token entry exists in keycloak auth config object + msg = f"A token entry exists in {ctx.obj['config_file']}\n" + click.echo(click.style(msg, fg="green")) + next_action = click.prompt( + text="Would you like to verify it or generate a new one?", + type=click.Choice(["verify", "generate"]), + default="verify", + ) + if next_action == "verify": + raw_token = ctx.obj["keycloak"]["token"] - bearer_token = raw_token.strip() + if not raw_token: + if not username: + username = click.prompt(text="Username") + raw_token = ctx.invoke(generate_token, username=username) + + assert raw_token + refresh_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)[ + # userinfo endpoint needs an access_token + access_token = oidc_client.refresh_token(refresh_token=refresh_token)[ "access_token" ] oidc_info = oidc_client.userinfo(access_token=access_token) @@ -229,17 +252,24 @@ def set_token(ctx: Context, token: str): f"Token verification success for username {oidc_info['preferred_username']}" ) click.echo(click.style(msg, fg="green")) + # Store the valid token into keycloak auth config object + ctx.obj["keycloak"]["token"] = refresh_token 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"]}})) + # Save auth configuration file? + if not click.confirm( + "Save authentication settings to\n" f"{ctx.obj['config_file']}?" + ): + sys.exit(1) + + # Save configuration to file + config_path = Path(ctx.obj["config_file"]) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.safe_dump({"keycloak": ctx.obj["keycloak"]})) - msg = "Token successfully added to configuration file '%s'" - msg %= click.format_filename(str(config_file_path)) + msg = "\nAuthentication configuration file '%s' written successfully" + msg %= click.format_filename(str(config_path)) click.echo(click.style(msg, fg="green"))