diff --git a/airflow-ctl/docs/images/command_hashes.txt b/airflow-ctl/docs/images/command_hashes.txt index 5922aa473cf1e..2fb1a6d94dd65 100644 --- a/airflow-ctl/docs/images/command_hashes.txt +++ b/airflow-ctl/docs/images/command_hashes.txt @@ -11,4 +11,4 @@ pools:03fc7d948cbecf16ff8d640eb8f0ce43 providers:1c0afb2dff31d93ab2934b032a2250ab variables:0354f8f4b0dde1c3771ed1568692c6ae version:31f4efdf8de0dbaaa4fac71ff7efecc3 -auth login:9fe2bb1dd5c602beea2eefb33a2b20a8 +auth login:214f6c3437bcd7765d98185913d48c62 diff --git a/airflow-ctl/docs/images/output_auth_login.svg b/airflow-ctl/docs/images/output_auth_login.svg index 754353b3c68b3..0523e9fa5f515 100644 --- a/airflow-ctl/docs/images/output_auth_login.svg +++ b/airflow-ctl/docs/images/output_auth_login.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + - + - + - - Usage:airflowctl auth login [-h] [--api-tokenAPI_TOKEN] -                             [--api-urlAPI_URL] [-eENV] -                             [--passwordPASSWORD] [--skip-keyring] -                             [--usernameUSERNAME] - -Login to the metadata database - -Options: --h--helpshow this help message and exit ---api-tokenAPI_TOKEN -The token to use for authentication ---api-urlAPI_URLThe URL of the metadata database API --e--envENVThe environment to run the command in ---passwordPASSWORDThe password to use for authentication ---skip-keyringSkip storing credentials in keyring ---usernameUSERNAMEThe username to use for authentication + + Usage:airflowctl auth login [-h] [--api-tokenAPI_TOKEN] +                             [--api-urlAPI_URL] [-eENV] +                             [--passwordPASSWORD] [--print-token] +                             [--skip-keyring] [--usernameUSERNAME] [-y] + +Login to the metadata database + +Options: +-h--helpshow this help message and exit +--api-tokenAPI_TOKEN +The token to use for authentication +--api-urlAPI_URLThe URL of the metadata database API +-e--envENVThe environment to run the command in +--passwordPASSWORDThe password to use for authentication +--print-tokenPrint the auth token to stdout after a successful  +login +--skip-keyringSkip storing credentials in keyring +--usernameUSERNAMEThe username to use for authentication +-y--yesSkip the confirmation prompt when --print-token is  +used diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py index 28dce22805dd5..a7c125be448c2 100755 --- a/airflow-ctl/src/airflowctl/ctl/cli_config.py +++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py @@ -246,6 +246,20 @@ def string_lower_type(val): dest="password", help="The password to use for authentication", ) +ARG_AUTH_PRINT_TOKEN = Arg( + flags=("--print-token",), + dest="print_token", + default=False, + action="store_true", + help="Print the auth token to stdout after a successful login", +) +ARG_AUTH_YES = Arg( + flags=("-y", "--yes"), + dest="yes", + default=False, + action="store_true", + help="Skip the confirmation prompt when --print-token is used", +) # Dag Commands Args ARG_DAG_ID = Arg( @@ -833,6 +847,8 @@ def merge_commands( ARG_AUTH_USERNAME, ARG_AUTH_PASSWORD, ARG_AUTH_SKIP_KEYRING, + ARG_AUTH_PRINT_TOKEN, + ARG_AUTH_YES, ), ), ActionCommand( diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py index 809cde294e8ca..55bdf605d3731 100644 --- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py +++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py @@ -33,6 +33,22 @@ from airflowctl.ctl.console_formatting import AirflowConsole +def _maybe_print_token(token: str, yes: bool) -> None: + """Warn the user on stderr, confirm, then print the token to stdout.""" + if not yes: + rich.print( + "[yellow]Warning: printing your auth token to stdout exposes it in your terminal " + "history and to anyone who can see your screen.[/yellow]", + file=sys.stderr, + ) + rich.print("Are you sure you want to print the token? [y/N]: ", end="", file=sys.stderr) + answer = input().strip().lower() + if answer not in ("y", "yes"): + rich.print("[red]Aborted.[/red]") + return + print(token) + + @provide_api_client(kind=ClientKind.AUTH) def login(args, api_client=NEW_API_CLIENT) -> None: """Login to a provider.""" @@ -60,9 +76,6 @@ def login(args, api_client=NEW_API_CLIENT) -> None: # Username + password login (from args or interactively prompted) if username and password: - if args.skip_keyring: - rich.print("[red]The --skip-keyring is not compatible with username and password login.") - sys.exit(1) credentials = Credentials( api_url=args.api_url, api_token="", @@ -79,8 +92,10 @@ def login(args, api_client=NEW_API_CLIENT) -> None: ) ) credentials.api_token = login_response.access_token - credentials.save() - rich.print(success_message) + credentials.save(args.skip_keyring) + rich.print(success_message, file=sys.stderr) + if args.print_token: + _maybe_print_token(credentials.api_token, args.yes) return except Exception as e: rich.print(f"[red]Login failed: {e}") @@ -102,6 +117,8 @@ def login(args, api_client=NEW_API_CLIENT) -> None: api_environment=args.env, ).save(args.skip_keyring) rich.print(success_message) + if args.print_token: + _maybe_print_token(token, args.yes) def list_envs(args) -> None: diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py index e76fafc28adf9..39323951edf79 100644 --- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py +++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py @@ -225,6 +225,41 @@ def test_login_no_credentials_non_interactive_exits(self, api_client_maker): api_client=api_client, ) + @patch("airflowctl.api.client.keyring") + def test_login_with_username_password_skip_keyring_and_print_token( + self, mock_keyring, api_client_maker, capsys + ): + """--username --password --skip-keyring --print-token --yes succeeds without touching keyring.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + auth_command.login( + self.parser.parse_args( + [ + "auth", + "login", + "--api-url", + "http://localhost:8080", + "--username", + "test_user", + "--password", + "test_password", + "--skip-keyring", + "--print-token", + "--yes", + ] + ), + api_client=api_client, + ) + + mock_keyring.set_password.assert_not_called() + assert "TEST_TOKEN" in capsys.readouterr().out + @patch("airflowctl.api.client.keyring") def test_login_with_username_and_password_no_keyring_backend(self, mock_keyring, api_client_maker): """Test that login fails when no keyring backend is available.""" @@ -256,6 +291,137 @@ def test_login_with_username_and_password_no_keyring_backend(self, mock_keyring, ) +class TestPrintToken: + """Tests for --print-token and --yes flags on auth login.""" + + parser = cli_parser.get_parser() + login_response = LoginResponse(access_token="TEST_TOKEN") + + def _make_args(self, extra: list[str]): + return self.parser.parse_args(["auth", "login", "--api-url", "http://localhost:8080"] + extra) + + @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"}) + @patch("airflowctl.api.client.keyring") + def test_print_token_with_yes_prints_to_stdout(self, mock_keyring, api_client_maker, capsys): + """--print-token --yes prints the token to stdout without prompting.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + auth_command.login(self._make_args(["--print-token", "--yes"]), api_client=api_client) + + captured = capsys.readouterr() + assert "TEST_TOKEN" in captured.out + + @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"}) + @patch("airflowctl.api.client.keyring") + def test_print_token_user_confirms(self, mock_keyring, api_client_maker, capsys): + """--print-token prints the token when the user answers 'y' at the prompt.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + with patch("builtins.input", return_value="y"): + auth_command.login(self._make_args(["--print-token"]), api_client=api_client) + + captured = capsys.readouterr() + assert "TEST_TOKEN" in captured.out + + @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"}) + @patch("airflowctl.api.client.keyring") + def test_print_token_user_declines(self, mock_keyring, api_client_maker, capsys): + """--print-token does not print the token when the user answers 'n'.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + with patch("builtins.input", return_value="n"): + auth_command.login(self._make_args(["--print-token"]), api_client=api_client) + + captured = capsys.readouterr() + assert "TEST_TOKEN" not in captured.out + + @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"}) + @patch("airflowctl.api.client.keyring") + def test_no_print_token_flag_does_not_print(self, mock_keyring, api_client_maker, capsys): + """Without --print-token, the token never appears on stdout.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + auth_command.login(self._make_args([]), api_client=api_client) + + captured = capsys.readouterr() + assert "TEST_TOKEN" not in captured.out + + @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"}) + @patch("airflowctl.api.client.keyring") + def test_print_token_warning_goes_to_stderr(self, mock_keyring, api_client_maker, capsys): + """The security warning is written to stderr, not stdout.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + with patch("builtins.input", return_value="y"): + auth_command.login(self._make_args(["--print-token"]), api_client=api_client) + + captured = capsys.readouterr() + assert "Warning" in captured.err + assert "TEST_TOKEN" not in captured.err + + @patch("airflowctl.api.client.keyring") + def test_print_token_with_username_password_and_yes(self, mock_keyring, api_client_maker, capsys): + """--print-token --yes also works for username/password login.""" + api_client = api_client_maker( + path="/auth/token/cli", + response_json=self.login_response.model_dump(), + expected_http_status_code=201, + kind=ClientKind.AUTH, + ) + mock_keyring.set_password = mock.MagicMock() + + auth_command.login( + self.parser.parse_args( + [ + "auth", + "login", + "--api-url", + "http://localhost:8080", + "--username", + "user", + "--password", + "pass", + "--print-token", + "--yes", + ] + ), + api_client=api_client, + ) + + captured = capsys.readouterr() + assert "TEST_TOKEN" in captured.out + + class TestListEnvs: parser = cli_parser.get_parser()