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()