This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 283ab98d659 Keep airflowctl auth token stdout limited to the token 
(#76134)
283ab98d659 is described below

commit 283ab98d65943fefc74c70cdffcd8982c1f4479a
Author: Shawn Sun <[email protected]>
AuthorDate: Sun Nov 1 00:30:52 2026 -0700

    Keep airflowctl auth token stdout limited to the token (#76134)
    
    * Keep airflowctl auth token stdout limited to the token
    
    `airflowctl auth token` is meant to be captured, e.g.
    `TOKEN=$(airflowctl auth token ...)`. The username prompt was written to
    stdout via input(), so in that usage the prompt was invisible to the user
    and ended up inside the captured value; the failure message also went to
    stdout. Without a TTY and without credentials, the command crashed with
    an EOFError traceback instead of a clear error.
    
    Prompts and errors now go to stderr, and missing credentials in a
    non-interactive context exit with a clear message, mirroring what
    `airflowctl auth login` already does.
    
    * Let airflowctl auth token read a piped password without a TTY
    
    Only refuse to run when the username is missing and stdin is not a
    TTY (or is closed); the password still goes through getpass, which
    reads from /dev/tty when available and falls back to stdin. This keeps
    'echo "$PW" | airflowctl auth token --username u' and prompts on
    /dev/tty with redirected stdin working, so scripts need not put the
    password on argv. EOF while reading the password exits 1 with a clear
    stderr message. Document the non-interactive options in the help text.
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |   4 +-
 .../src/airflowctl/ctl/commands/auth_command.py    |  34 ++++++-
 .../airflow_ctl/ctl/commands/test_auth_command.py  | 104 +++++++++++++++++++++
 3 files changed, 138 insertions(+), 4 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 361443f29e3..9ebabc729e0 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -1257,7 +1257,9 @@ AUTH_COMMANDS = (
         help="Generate and print a JWT token for the given credentials",
         description=(
             "Authenticate with username and password and print the access 
token to stdout. "
-            "Username and password are prompted interactively if not provided."
+            "Username and password are prompted interactively if not provided. 
"
+            "In non-interactive runs, pass --username and either pass 
--password "
+            "or pipe the password on stdin."
         ),
         
func=lazy_load_command("airflowctl.ctl.commands.auth_command.get_token"),
         args=(
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index b7bfc1a3546..a2e87b307d2 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -115,8 +115,36 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
 def get_token(args, api_client=NEW_API_CLIENT) -> None:
     """Generate and print a JWT token for the given credentials to stdout."""
     api_url = args.api_url or get_default_api_url()
-    username = args.username or input("Username: ")
-    password = args.password or getpass.getpass("Password: ")
+    username = args.username
+    password = args.password
+
+    # stdout is reserved for the token (e.g. TOKEN=$(airflowctl auth token)), 
so prompts
+    # and errors go to stderr.
+    if not username:
+        if not (sys.stdin and sys.stdin.isatty()):
+            rich.print("[red]No username provided.[/red]", file=sys.stderr)
+            rich.print(
+                "[green]Please pass:[/green] [blue]--username[/blue] "
+                "to generate a token in a non-interactive context.",
+                file=sys.stderr,
+            )
+            sys.exit(1)
+        sys.stderr.write("Username: ")
+        sys.stderr.flush()
+        username = input()
+    if not password:
+        # getpass reads from /dev/tty when available and falls back to stdin, 
so the
+        # password can still be piped in non-interactive runs instead of 
passed on argv.
+        try:
+            password = getpass.getpass("Password: ")
+        except EOFError:
+            rich.print("[red]No password provided.[/red]", file=sys.stderr)
+            rich.print(
+                "[green]Please pass:[/green] [blue]--password[/blue] or pipe 
the password on stdin "
+                "to generate a token in a non-interactive context.",
+                file=sys.stderr,
+            )
+            sys.exit(1)
 
     try:
         api_client.refresh_base_url(base_url=api_url, kind=ClientKind.AUTH)
@@ -125,7 +153,7 @@ def get_token(args, api_client=NEW_API_CLIENT) -> None:
         )
         print(login_response.access_token)
     except Exception as e:
-        rich.print(f"[red]Token generation failed: {e}[/red]")
+        rich.print(f"[red]Token generation failed: {e}[/red]", file=sys.stderr)
         sys.exit(1)
 
 
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 5bfad37c84f..9cd7cf8d16d 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
@@ -299,6 +299,110 @@ class TestCliAuthCommands:
             )
 
 
+class TestCliAuthToken:
+    parser = cli_parser.get_parser()
+    token_args = ["auth", "token", "--api-url", "http://localhost:8080";]
+
+    @staticmethod
+    def _stdin(is_tty: bool):
+        stdin = mock.Mock(spec_set=["isatty"])
+        stdin.isatty.return_value = is_tty
+        return stdin
+
+    def _api_client(self, api_client_maker, status_code: int = 201, 
response_json: dict | None = None):
+        return api_client_maker(
+            path="/auth/token/cli",
+            response_json=response_json or 
LoginResponse(access_token="TEST_TOKEN").model_dump(),
+            expected_http_status_code=status_code,
+            kind=ClientKind.AUTH,
+        )
+
+    @patch("airflowctl.ctl.commands.auth_command.getpass.getpass", 
return_value="prompted_pass")
+    @patch("builtins.input", return_value="prompted_user")
+    def test_token_prompts_on_stderr_interactively(self, mock_input, 
mock_getpass, api_client_maker, capsys):
+        with patch("sys.stdin", self._stdin(is_tty=True)):
+            auth_command.get_token(
+                self.parser.parse_args(self.token_args),
+                api_client=self._api_client(api_client_maker),
+            )
+
+        mock_input.assert_called_once_with()
+        mock_getpass.assert_called_once_with("Password: ")
+        captured = capsys.readouterr()
+        assert captured.out == "TEST_TOKEN\n"
+        assert captured.err == "Username: "
+
+    @pytest.mark.parametrize(
+        "credential_args",
+        [
+            pytest.param([], id="no-credentials"),
+            pytest.param(["--password", "pass"], id="password-only"),
+        ],
+    )
+    @pytest.mark.parametrize("closed_stdin", [False, True], 
ids=["non-tty-stdin", "closed-stdin"])
+    @patch("airflowctl.ctl.commands.auth_command.getpass.getpass")
+    @patch("builtins.input")
+    def test_token_missing_username_non_interactive_exits(
+        self, mock_input, mock_getpass, closed_stdin, credential_args, 
api_client_maker, capsys
+    ):
+        stdin = None if closed_stdin else self._stdin(is_tty=False)
+        with patch("sys.stdin", stdin), pytest.raises(SystemExit) as exc_info:
+            auth_command.get_token(
+                self.parser.parse_args([*self.token_args, *credential_args]),
+                api_client=self._api_client(api_client_maker),
+            )
+
+        assert exc_info.value.code == 1
+        mock_input.assert_not_called()
+        mock_getpass.assert_not_called()
+        captured = capsys.readouterr()
+        assert captured.out == ""
+        assert "No username provided." in captured.err
+        assert "--username" in captured.err
+
+    @patch("airflowctl.ctl.commands.auth_command.getpass.getpass", 
return_value="piped_pass")
+    def test_token_reads_piped_password_non_interactive(self, mock_getpass, 
api_client_maker, capsys):
+        with patch("sys.stdin", self._stdin(is_tty=False)):
+            auth_command.get_token(
+                self.parser.parse_args([*self.token_args, "--username", 
"user"]),
+                api_client=self._api_client(api_client_maker),
+            )
+
+        mock_getpass.assert_called_once_with("Password: ")
+        assert capsys.readouterr().out == "TEST_TOKEN\n"
+
+    @patch("airflowctl.ctl.commands.auth_command.getpass.getpass", 
side_effect=EOFError)
+    def test_token_missing_password_non_interactive_exits(self, mock_getpass, 
api_client_maker, capsys):
+        with patch("sys.stdin", self._stdin(is_tty=False)), 
pytest.raises(SystemExit) as exc_info:
+            auth_command.get_token(
+                self.parser.parse_args([*self.token_args, "--username", 
"user"]),
+                api_client=self._api_client(api_client_maker),
+            )
+
+        assert exc_info.value.code == 1
+        captured = capsys.readouterr()
+        assert captured.out == ""
+        assert "No password provided." in captured.err
+        assert "--password" in captured.err
+
+    def test_token_failure_reported_on_stderr(self, api_client_maker, capsys):
+        with (
+            patch("sys.stdin", self._stdin(is_tty=False)),
+            pytest.raises(SystemExit) as exc_info,
+        ):
+            auth_command.get_token(
+                self.parser.parse_args([*self.token_args, "--username", 
"user", "--password", "wrong"]),
+                api_client=self._api_client(
+                    api_client_maker, status_code=401, 
response_json={"detail": "Invalid credentials"}
+                ),
+            )
+
+        assert exc_info.value.code == 1
+        captured = capsys.readouterr()
+        assert "Token generation failed" in captured.err
+        assert "Token generation failed" not in captured.out
+
+
 class TestListEnvs:
     parser = cli_parser.get_parser()
 

Reply via email to